# Management keys

> Provision, limit, rotate and revoke inference keys from your own code with a management key.

Source: https://0.0.0.0:3000/docs/account/management-keys
Documentation index: https://0.0.0.0:3000/docs/llms.txt

A management key lets your backend administer inference keys through the compatible API: create a key per customer or service, adjust spending limits, disable a leaked key, delete keys you no longer need and read account balances. The endpoints follow OpenRouter's key-management shape, with one addition: every new key names its permanent billing currency.

## What a management key can and cannot do

| A management key can | A management key cannot |
| --- | --- |
| List, create, read, update and delete **inference** keys in its account | Run inference or list models |
| Read its own metadata with `GET /key` | Create, list or revoke other management keys |
| Read the account's credit totals with `GET /credits` | Change a key's billing currency or expiry after creation |

Management keys are created only in the dashboard: open [Keys](/dashboard/keys), choose **Create key**, and select **Management** as the **Type**. They have no currency or spending limit, and the secret is shown once. Disable or delete them on the same page.

### Authority follows the creator

A management key acts with the **current** role of the person who created it:

- Created by the personal account owner, or by an organization **owner** or **administrator**: it manages every inference key in the account.
- Created by any other organization member: it sees and manages only the inference keys that member created. Keys it creates are attributed to that member.
- If the creator is removed from the organization or their account is suspended, the management key stops authenticating. A role change applies to the next request.

Signing the creator out of the dashboard doesn't revoke their management keys; disable or delete them in [Keys](/dashboard/keys).

A management key can create keys that spend the account's balance. Keep it in the one service that provisions keys, never in application servers that only call models.

## Endpoints

All management endpoints take the management key as a bearer token. Using an inference key on them returns `401` `invalid_credentials`.

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/keys` | [List inference keys](/docs/api-reference/keys/list-keys) |
| `POST` | `/keys` | [Create an inference key](/docs/api-reference/keys/create-key) |
| `GET` | `/keys/{hash}` | [Read one key](/docs/api-reference/keys/get-key) |
| `PATCH` | `/keys/{hash}` | [Rename, disable or change the limit](/docs/api-reference/keys/update-key) |
| `DELETE` | `/keys/{hash}` | [Revoke a key](/docs/api-reference/keys/delete-key) |
| `GET` | `/key` | [Inspect the management key itself](/docs/api-reference/keys/get-current-key) |
| `GET` | `/credits` | [Read credit totals in one currency](#read-balances) |

`{hash}` is the key's non-secret `hash` field: the SHA-256 hex digest of the full key. You can compute it from a leaked key when you have no other record of it.

Every key object has the fields described in [API keys](/docs/account/api-keys#inspect-the-current-key), without `is_management_key`.

## List keys

```request
{
  "method": "GET",
  "path": "/keys",
  "auth": "management",
  "query": { "include_disabled": "true", "offset": "0" }
}
```

:::fields Query parameters
- `include_disabled` `boolean` default `false` — Include disabled keys. Deleted keys are never listed.
- `offset` `integer` default `0` — Number of keys to skip.
:::

The response is `{"data": [...]}` with at most **100** keys per page. Only inference keys are returned. To read everything, advance `offset` by the number of keys returned until a page has fewer than 100.

## Create a key

```request
{
  "method": "POST",
  "path": "/keys",
  "auth": "management",
  "body": {
    "name": "customer-4821",
    "billing_currency": "USD",
    "limit": 50,
    "limit_reset": "monthly"
  }
}
```

:::fields Request body
- `name` `string` required — 1–100 characters after trimming.
- `billing_currency` `string` required — `USD` or `BTC`. Permanent. The currency must be available to your account.
- `limit` `number | null` — Spending limit in the key's currency, zero or greater. Omit or send `null` for no limit. Parsed as an exact decimal.
- `limit_reset` `string | null` — `daily`, `weekly` or `monthly` (UTC windows). Requires `limit`. Omit or send `null` for a lifetime limit.
- `expires_at` `string | null` — A future time in UTC, such as `2026-12-31T23:59:59Z`. Times with a non-UTC offset are rejected.
:::

The response is `201 Created`:

```json title="Response"
{
  "data": {
    "hash": "3b7c9e1f5a2d4c6e8f0a1b3c5d7e9f1a2b4c6d8e0f1a3b5c7d9e1f2a4b6c8d0e",
    "label": "sk-example-v1-8d2e...41b7",
    "name": "customer-4821",
    "billing_currency": "USD",
    "disabled": false,
    "limit": 50,
    "limit_remaining": 50,
    "limit_reset": "monthly",
    "usage": 0,
    "usage_daily": 0,
    "usage_weekly": 0,
    "usage_monthly": 0,
    "created_at": "2026-09-28T10:04:11Z",
    "updated_at": null,
    "expires_at": null
  },
  "key": "sk-..."
}
```

`key` is the secret. It appears only in this response, so store it immediately. Keep `data.hash` with your own customer record to manage the key later without the secret.

## Read a key

```request
{
  "method": "GET",
  "path": "/keys/3b7c9e1f5a2d4c6e8f0a1b3c5d7e9f1a2b4c6d8e0f1a3b5c7d9e1f2a4b6c8d0e",
  "auth": "management"
}
```

Returns `{"data": {...}}`. A key that doesn't exist, was deleted, or is outside the management key's authority returns `404` `not_found`.

## Update a key

```request
{
  "method": "PATCH",
  "path": "/keys/3b7c9e1f5a2d4c6e8f0a1b3c5d7e9f1a2b4c6d8e0f1a3b5c7d9e1f2a4b6c8d0e",
  "auth": "management",
  "body": { "limit": 75, "limit_reset": "monthly" }
}
```

:::fields Request body (at least one field)
- `name` `string` — New name, 1–100 characters.
- `disabled` `boolean` — `true` stops the key authenticating; `false` re-enables it.
- `limit` `number | null` — New spending limit. `null` removes the limit and its reset window.
- `limit_reset` `string | null` — `daily`, `weekly`, `monthly` or `null`. Setting a window on a key without a limit is rejected.
:::

`billing_currency` and `expires_at` cannot be changed; sending them, or any other unknown field, returns `400` `invalid_request`. The response is the updated key.

## Delete a key

```request
{
  "method": "DELETE",
  "path": "/keys/3b7c9e1f5a2d4c6e8f0a1b3c5d7e9f1a2b4c6d8e0f1a3b5c7d9e1f2a4b6c8d0e",
  "auth": "management"
}
```

```json title="Response"
{ "deleted": true }
```

Deletion is permanent: clients using the key get `401` immediately and a later `GET /keys/{hash}` returns `404`. Past usage remains in Activity.

## Errors

| Status | `error_type` | Cause |
| --- | --- | --- |
| `400` | `invalid_request` | Unknown field, invalid value, `limit_reset` without `limit`, non-UTC `expires_at`, currency not available, missing `currency` on `/credits` |
| `401` | `invalid_credentials` | Missing, invalid, disabled, deleted or wrong-kind key |
| `404` | `not_found` | No key with that hash within the management key's authority |
| `413` | `payload_too_large` | Body over 32 KiB |
| `415` | `unsupported_media_type` | Body sent without `Content-Type: application/json` |
| `429` | `rate_limited` | Too many requests or repeated failed authentications; honor `Retry-After` |

## Automation recipes

The recipes read the management key from `NUMES_MANAGEMENT_KEY`.

### Provision a key per customer with a monthly limit

Create one key per customer so each has its own budget and its own breakdown in [Activity](/dashboard/activity).

:::code-group
```python title="Python"
import os
import requests

BASE_URL = "https://api.numes.ai/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['NUMES_MANAGEMENT_KEY']}"}


def provision_customer_key(customer_id: str, monthly_usd: int) -> tuple[str, str]:
    """Return (hash, secret). Store the secret in your secret manager."""
    response = requests.post(
        f"{BASE_URL}/keys",
        headers=HEADERS,
        json={
            "name": f"customer-{customer_id}",
            "billing_currency": "USD",
            "limit": monthly_usd,
            "limit_reset": "monthly",
        },
        timeout=10,
    )
    response.raise_for_status()
    body = response.json()
    return body["data"]["hash"], body["key"]
```

```typescript title="TypeScript"
const BASE_URL = "https://api.numes.ai/api/v1";
const HEADERS = {
  Authorization: `Bearer ${process.env.NUMES_MANAGEMENT_KEY}`,
  "Content-Type": "application/json",
};

export async function provisionCustomerKey(
  customerId: string,
  monthlyUsd: number,
): Promise<{ hash: string; secret: string }> {
  const response = await fetch(`${BASE_URL}/keys`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({
      name: `customer-${customerId}`,
      billing_currency: "USD",
      limit: monthlyUsd,
      limit_reset: "monthly",
    }),
    signal: AbortSignal.timeout(10_000),
  });
  if (!response.ok) throw new Error(`key creation failed: ${response.status}`);
  const body = await response.json();
  return { hash: body.data.hash, secret: body.key };
}
```
:::

### Rotate a key

Create the replacement with the same settings, deploy it, then delete the old key. The old key keeps working until the final step, so there is no gap.

:::code-group
```python title="Python"
def rotate_key(old_hash: str) -> tuple[str, str]:
    old = requests.get(f"{BASE_URL}/keys/{old_hash}", headers=HEADERS, timeout=10)
    old.raise_for_status()
    current = old.json()["data"]

    created = requests.post(
        f"{BASE_URL}/keys",
        headers=HEADERS,
        json={
            "name": current["name"],
            "billing_currency": current["billing_currency"],
            "limit": current["limit"],
            "limit_reset": current["limit_reset"],
        },
        timeout=10,
    )
    created.raise_for_status()
    body = created.json()
    return body["data"]["hash"], body["key"]


def retire_key(old_hash: str) -> None:
    """Call after every deployment has switched to the new key."""
    requests.delete(f"{BASE_URL}/keys/{old_hash}", headers=HEADERS, timeout=10).raise_for_status()
```

```typescript title="TypeScript"
export async function rotateKey(oldHash: string) {
  const old = await fetch(`${BASE_URL}/keys/${oldHash}`, { headers: HEADERS });
  if (!old.ok) throw new Error(`key lookup failed: ${old.status}`);
  const { data: current } = await old.json();

  const created = await fetch(`${BASE_URL}/keys`, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({
      name: current.name,
      billing_currency: current.billing_currency,
      limit: current.limit,
      limit_reset: current.limit_reset,
    }),
  });
  if (!created.ok) throw new Error(`key creation failed: ${created.status}`);
  const body = await created.json();
  return { hash: body.data.hash as string, secret: body.key as string };
}

export async function retireKey(oldHash: string) {
  const response = await fetch(`${BASE_URL}/keys/${oldHash}`, {
    method: "DELETE",
    headers: HEADERS,
  });
  if (!response.ok) throw new Error(`key deletion failed: ${response.status}`);
}
```
:::

Between the two calls, verify the rollout by calling `GET /key` with the new secret from each environment.

### Disable a leaked key

If a secret appears somewhere it should not, compute its hash and disable it at once. Disabling is reversible, so you can investigate before deleting.

:::code-group
```python title="Python"
import hashlib


def disable_leaked_key(plaintext: str) -> None:
    key_hash = hashlib.sha256(plaintext.encode()).hexdigest()
    response = requests.patch(
        f"{BASE_URL}/keys/{key_hash}",
        headers=HEADERS,
        json={"disabled": True},
        timeout=10,
    )
    response.raise_for_status()
```

```typescript title="TypeScript"
import { createHash } from "node:crypto";

export async function disableLeakedKey(plaintext: string) {
  const hash = createHash("sha256").update(plaintext).digest("hex");
  const response = await fetch(`${BASE_URL}/keys/${hash}`, {
    method: "PATCH",
    headers: HEADERS,
    body: JSON.stringify({ disabled: true }),
  });
  if (!response.ok) throw new Error(`disable failed: ${response.status}`);
}
```
:::

Then review what the key spent in [Activity](/dashboard/activity), issue a replacement, and delete the leaked key.

### Report spend per key

List every key and read its settled spend for the current UTC month:

```python title="Python"
def monthly_spend() -> dict[str, float]:
    spend, offset = {}, 0
    while True:
        response = requests.get(
            f"{BASE_URL}/keys",
            headers=HEADERS,
            params={"include_disabled": "true", "offset": offset},
            timeout=10,
        )
        response.raise_for_status()
        page = response.json()["data"]
        for key in page:
            spend[key["name"]] = key["usage_monthly"]
        if len(page) < 100:
            return spend
        offset += len(page)
```

Values are in each key's own currency; never add USD and BTC keys together. For exact arithmetic, parse the raw JSON numbers as decimals (for example `json.loads(text, parse_float=Decimal)` in Python).

## Read balances

`GET /credits` reports the account's credit totals in one currency. `currency` is required (`USD` or `BTC`); balances in different currencies are never combined.

```request
{
  "method": "GET",
  "path": "/credits",
  "auth": "management",
  "query": { "currency": "USD" }
}
```

```json title="Response"
{
  "data": {
    "currency": "USD",
    "total_credits": 250,
    "total_usage": 61.402116
  }
}
```

:::fields Response `data`
- `currency` `string` — The requested currency. BTC values are in sats.
- `total_credits` `number` — Everything credited to the account, net of refunds, disputes and corrections.
- `total_usage` `number` — Lifetime settled usage charges.
:::

`total_credits - total_usage` is the posted balance. It doesn't subtract funds held for running requests; **Available** in [Billing](/dashboard/billing) does. Per-request cost is in [`GET /generation`](/docs/account/usage-accounting#look-up-a-generation).

## Next steps

:::cards
- {key} [API keys](/docs/account/api-keys) Key settings, limits and rotation in the dashboard.
- {code} [Keys API reference](/docs/api-reference/keys/create-key) Full schemas for every key endpoint.
- {receipt} [Usage accounting](/docs/account/usage-accounting) What each key's usage figures mean.
- {building} [Organizations](/docs/account/organizations) How roles limit what a management key can reach.
:::
