# Issue a key and give it a budget

**What you get at the end:** a key a team can put in their `OPENAI_API_KEY` slot, that stops
working when the money runs out, without you having to watch it.

The examples create an organization budget and a virtual key. Replace the example names and caps with your intended policy.

## First, the shape of the thing

**An organisation holds the money; a key spends it.** You do not put a budget on a key and hope
every key gets one. You put it on the organisation, and every key underneath it draws from the same
pot. That is why one team overspending shows up as one bill rather than a hunt through twelve keys.

A key can carry its own smaller cap as well, which is how you hand a contractor a key that cannot
spend more than fifty dollars of your organisation's budget. The organisation's cap still applies
above it; the tighter of the two wins.

## Log in

This walkthrough uses an admin session: keep its cookie jar and send the CSRF token on mutations. HTTP Basic is also supported for scripts; see [admin authentication](../reference/admin-api.md#authentication-once).

```bash
J=$(mktemp)
curl -s -c "$J" -X POST localhost:8080/admin/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"YOUR_ADMIN_PASSWORD"}'

X=$(grep XSRF-TOKEN "$J" | awk '{print $7}')
```

Every write below sends both: `-b "$J"` for the session and `-H "X-XSRF-TOKEN: $X"` for the token.
Without the token you get `403 Access denied`, which reads like a permissions problem and is not.

## Create the organisation

```bash
ORG=$(curl -s -b "$J" -H "X-XSRF-TOKEN: $X" \
  -X POST localhost:8080/admin/organizations \
  -H 'Content-Type: application/json' \
  -d '{"name":"acme"}' | jq -r .id)
```

## Give it a monthly budget

A budget is a limit rule that counts money over a calendar window. Twenty-five dollars a month,
resetting at midnight UTC on the first:

```bash
curl -s -b "$J" -H "X-XSRF-TOKEN: $X" \
  -X POST "localhost:8080/admin/organizations/$ORG/rules" \
  -H 'Content-Type: application/json' \
  -d '{"amount":"cost","budget":25,
       "window":{"kind":"calendar","count":1,"unit":"month",
                 "anchor_zone":"UTC","anchor_reset_time":"00:00","anchor_day":1},
       "action":"block"}'
```

### Choose an enforcement action

 `block` refuses the request when the cap is reached. If you
would rather be told than stopped, `warn` counts and logs without ever refusing, which is the
honest way to find out what a team actually spends before you cap them. `degrade` sends them to a
cheaper model instead of refusing, which keeps the service up at lower quality.

## Issue the key

```bash
curl -s -b "$J" -H "X-XSRF-TOKEN: $X" \
  -X POST "localhost:8080/admin/organizations/$ORG/keys" \
  -H 'Content-Type: application/json' \
  -d '{"name":"acme-prod","kind":"service"}'
```

### Choose a window

`kind` is required and the choice matters. A `service` key belongs to the organisation and
keeps working when the person who created it leaves. A `personal` key is bounded by its creator's
account and stops the moment that account is disabled. Omit it and the request is refused with a
message explaining exactly this, which is deliberate: a key that outlives its owner by accident is
a security problem, and a key that dies with a departure by surprise is an outage.

The response contains the key **once**:

```json
{ "id": "...", "key": "pointsman_test_77658dca...", "key_prefix": "pointsman_test_77658dca",
  "budget_usd": null, "rate_limit_rpm": null, "scope_id": "..." }
```

### Understand inherited budgets

`budget_usd: null` does not mean unlimited. It means this key has no cap of its own, and draws
on the organisation's. The prefix is what you will see in logs and on the dashboard; the full key
is shown only here, because only a hash of it is stored.

## Use it

```bash
curl -s localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer pointsman_test_77658dca..." \
  -H 'Content-Type: application/json' \
  -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hello"}]}'
```

## Check that the budget is really enforced

Send a request and look at the response headers. If a rule applied to you, the gateway says how
much room is left:

```
X-RateLimit-Remaining-Requests: 999
```

**An absent header means no rule of that kind matched**, which is not the same as unlimited - it
means nothing you configured governs that metric for that key. If you set a budget and see no
budget headroom reported, the rule is attached somewhere the request does not pass through, and
that is worth fixing before you trust it.

## What happens if Redis goes down

Rate-limit checks can fail open when the counter store is unreachable. Spend-cap admission depends on `pointsman.budget.failure-mode`:

| Mode | Enforcing spend cap applies | No enforcing spend cap |
|---|---|---|
| `deny` (default) | Refuse `503 budget_unverifiable`; do not query the ledger | Continue without counter-store enforcement |
| `allow` (opt-in) | Check readable caps against committed ledger spend; skip and count caps the ledger cannot answer | Continue |

The ledger records the identity chain. Rules counted by authored tree scopes, platform ceilings, models or sessions cannot be reconstructed from that chain; each unanswerable read increments `pointsman.budget.oracle.unanswerable`. In `allow` mode, concurrent ledger reads are bounded by `POINTSMAN_BUDGET_LEDGER_FALLBACK_MAX_CONCURRENT`.

Ledger estimates do not provide atomic reservations, so `allow` can overspend. Neither mode bypasses a lost-trust fence. See [counter-store failure behavior](../gateway-controls.md#when-redis-is-down).

## What happens if Redis restarts or loses data

After any Redis
restart, failover, reload or data loss, a request governed by a blocking, degrading or throttling cost rule gets a 503
with the code `budget_recovering`. That happens under both failure modes, until the balance is re-established. A
clean restart that lost nothing refuses too, because nothing yet proves what it kept. Rate limits, warn-only budgets
and keys with no budget are unaffected.

To reopen a tenant's budgets, review retained counters, journal evidence and provider usage, including any database-backup gap. A ledger total or backup-time counter snapshot alone may be incomplete. Follow the [budget-trust recovery procedure](../operating-notes.md#recovering-budget-trust-after-counter-store-loss) before attesting:

```bash
curl -u admin:... https://gateway.example/admin/budget/trust
```

```bash
# Replace expected_generation: 1 with the generation from the inventory you reviewed.
curl -u admin:... -H 'Content-Type: application/json' https://gateway.example/admin/budget/trust/<tag>/attest --data '{"expected_generation":1,"evidence_ref":"reviewed spend including backup gap and unresolved attempts","balances":{"{<tag>}:limitrule:<rule>:<bucket>":100000}}'
```

The first command lists each tenant slot (its tag) with its state. The second opens a new generation where each named
counter starts at the balance you give, in micros. Every attestation is recorded in the admin audit log. A counter
whose current period began before the attestation and was not named in it stays refused. A new period starts at zero.
The first boot after upgrading adopts the counters already in Redis, once, so an upgrade does not refuse anything.
See [KI-7](../limitations.md) for what this phase does not yet do, including automatic recovery.

See [multi-instance.md](../design/README.md) for the full failure posture.

Full wire shapes for every door this guide touches: [the admin API reference](../reference/admin-api.md).
