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

# Budget keys

A budget key is a short-lived child of an ordinary Sunra API key, with a USD spending cap and its own call receipts. Use one per agent turn or run to give your agent limited LLM access and account for each end user's usage separately. The cap controls admission using estimated cost: actual settled spend can exceed it, so keep a margin in your own authorization hold. Sunra continues to bill the parent key's organization; a budget key is a cap plus attribution, not a wallet.

## Lifecycle at a glance

1. **Mint** with an ordinary key. Save the child `id`, your business `ref`, and the secret returned once.
2. **Use** the child secret for one or more LLM calls before expiry, with an explicit output limit and a call tag.
3. **Inspect** the key's live spend and call receipts with an ordinary key.
4. **Close** when the turn ends. Poll until `status: closed`, then settle your user's authorization hold using the final `spent_usd`.

All management requests use `https://api.sunra.ai` and `Authorization: Key $SUNRA_KEY`, where `SUNRA_KEY` is an active ordinary key. A budget key cannot call management endpoints. See [Authentication](/platform/authentication).

## Mint a budget key

Send `POST /v1/budget-keys` with a JSON body:

| Field                   | Required | Rules                                                                                                                                                                                                                                                            |
| ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cap_usd`               | Yes      | Positive decimal USD **string** matching `^\d+(?:\.\d{1,8})?$`. Range: `0.00000001`–`90071992.54740991`, inclusive. JSON numbers, zero, negative values, exponent notation, and more than 8 decimal places are rejected. Returned with exactly 8 decimal places. |
| `expires_at`            | Yes      | ISO datetime string with `Z` or a timezone offset. Must be in the future and at most 24 hours away when validated. Leave time for request processing.                                                                                                            |
| `ref`                   | No       | String of at most 256 characters for your turn or authorization reference. Use a nonempty value if you want to recover the key by `ref`. It is not an idempotency key.                                                                                           |
| `metadata`              | No       | JSON object whose serialized UTF-8 JSON is at most 8,192 bytes.                                                                                                                                                                                                  |
| `metadata.allowed_tags` | No       | Array of at most 32 strings, each at most 128 printable ASCII characters. If supplied, every call must send a tag in this set. An empty array refuses all calls.                                                                                                 |
| `models`                | No       | Array of 1–64 exact, lowercase public `owner/model` IDs within the parent key's allowlist; no wildcards. Each part is 1–64 characters, starts and ends with a lowercase letter or digit, and may contain `.`, `_`, or `-` internally.                            |

Omit optional fields instead of sending `null`. Unknown top-level fields are rejected. If `models` is omitted, the child inherits the parent's model restrictions. If supplied, calls must satisfy both that list and the parent's current allowlist.

This example uses `jq` to set expiry one hour from now and mint a \$0.05 key:

```bash theme={null}
EXPIRES_AT=$(jq -nr 'now + 3600 | strftime("%Y-%m-%dT%H:%M:%SZ")')
curl --fail-with-body -sS https://api.sunra.ai/v1/budget-keys \
  -H "Authorization: Key $SUNRA_KEY" \
  -H 'Content-Type: application/json' \
  -d "$(jq -n --arg expires "$EXPIRES_AT" '{
    cap_usd: "0.05",
    expires_at: $expires,
    ref: "turn-123",
    metadata: {allowed_tags: ["summary"]},
    models: ["google/gemini-2.5-flash"]
  }')"
```

A `201` response is the resource itself, with no `data` wrapper. Example values:

```json theme={null}
{
  "object": "budget_key",
  "id": "child_example",
  "parent_api_key_id": "parent_example",
  "status": "open",
  "cap_usd": "0.05000000",
  "expires_at": "2026-09-11T13:00:00.000Z",
  "ref": "turn-123",
  "metadata": {"allowed_tags": ["summary"]},
  "models": ["google/gemini-2.5-flash"],
  "spent_usd": "0.00000000",
  "calls": 0,
  "reserved_usd": "0.00000000",
  "in_flight": 0,
  "closed_at": null,
  "secret_key": "replace-with-the-returned-child-secret"
}
```

**Save `secret_key` immediately: mint returns it only once.** GET, list, receipts, and close never return it. **Mint is not idempotent**: repeating a request, even with the same `ref`, creates a different child. If the response is lost, use the recovery endpoint below to find and close the orphaned key before deciding whether to mint another.

## Call the LLM API with the child key

Use `https://api-llm.sunra.ai` with `Authorization: Bearer <child secret>`. These are the only supported endpoints:

| Endpoint                    | Supported calls                                            |
| --------------------------- | ---------------------------------------------------------- |
| `POST /v1/chat/completions` | Streaming and non-streaming [Chat Completions](/llm/chat). |
| `POST /v1/messages`         | Streaming and non-streaming [Messages](/llm/messages).     |
| `POST /v1/responses`        | Streaming and non-streaming [Responses](/llm/responses).   |
| `POST /v1/embeddings`       | Non-streaming [text embeddings](/llm/embeddings) only.     |

Other API operations, including budget-key management and native/media embeddings, refuse the child with `403 budget_key_route_forbidden`. An expired or revoked credential can fail authentication first; an unrecognized URL can still return `404`.

For Chat Completions, **send `max_tokens` or `max_completion_tokens` on every child request**, using the field supported by your model. Missing both returns `400 max_tokens_required`. The value must be a positive safe integer; if both fields are supplied, they must be equal. For Messages use `max_tokens`; for Responses use `max_output_tokens`. Those two APIs can fill a missing limit from the model's configured output limit; sending an explicit limit makes the intended allocation clear.

`n` may be omitted, but when present it must be the number `1`. `best_of` is rejected even when it is `1`.

A child request may carry only the portable generation parameters listed below, plus the output ceiling for the endpoint you are calling. Anything else — provider extension bags and alternative ceiling spellings such as `extra_body`, `generation_config`, `generationConfig`, `max_new_tokens` — returns `400 invalid_input`, because they can raise the real output limit after translation and invalidate the reservation. Accepted top-level fields: `model`, `provider`, `stream`, `stream_options`, `messages`, `input`, `system`, `instructions`, `tools`, `tool_choice`, `parallel_tool_calls`, `response_format`, `text`, `temperature`, `top_p`, `top_k`, `stop`, `stop_sequences`, `seed`, `presence_penalty`, `frequency_penalty`, `logit_bias`, `logprobs`, `top_logprobs`, `user`, `metadata`, `store`, `previous_response_id`, `include`, `truncation`, `reasoning`, `reasoning_effort`, `thinking`, `service_tier`, `safety_identifier`, `prompt_cache_key`, `prompt_cache_retention`, `verbosity`, `n`, `encoding_format`, `dimensions`. Requests made with an ordinary key are not restricted this way.

Label a call with `x-sunra-call-tag`, at most 128 printable ASCII characters. It appears as `tag` in the receipt. If the key has `metadata.allowed_tags`, a missing tag or a tag outside that set returns `403 budget_call_tag_forbidden`. Otherwise, the header is optional. Ordinary keys ignore it entirely.

Set `CHILD` to the returned secret. `-i` includes response headers:

```bash theme={null}
curl --fail-with-body -sS -i https://api-llm.sunra.ai/v1/chat/completions \
  -H "Authorization: Bearer $CHILD" \
  -H 'Content-Type: application/json' \
  -H 'x-sunra-call-tag: summary' \
  -d '{
    "model": "google/gemini-2.5-flash",
    "messages": [{"role": "user", "content": "Say hello in one sentence."}],
    "max_tokens": 128,
    "n": 1
  }'

# Example response header (alongside the JSON response body):
# x-sunra-prediction-id: chatcmpl_example
```

Save `x-sunra-prediction-id` to join this call to its receipt. The receipt's `id` is byte-identical to that header; do not substitute a provider response ID.

## Track spend

### Read the key

With `ID` set to the minted child `id`:

```bash theme={null}
curl --fail-with-body -sS "https://api.sunra.ai/v1/budget-keys/$ID" \
  -H "Authorization: Key $SUNRA_KEY"
```

The response has the same resource fields as mint, without `secret_key`:

| Field          | Meaning                                                                                                                                                               |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `spent_usd`    | Live spend while open or finalizing; the authoritative, immutable final spend once closed. USD string with 8 decimals, or `null` when the live figure is unavailable. |
| `reserved_usd` | Estimated cost held for calls in flight; `null` if unknown. Closed keys return `0.00000000`.                                                                          |
| `in_flight`    | Number of calls with an outstanding reservation; `null` if unknown. Closed keys return `0`.                                                                           |
| `calls`        | Call-attempt count, including unpaid attempts. Provisional until closed and potentially `null` when unavailable; use the closed count for reconciliation.             |
| `status`       | `open`, `finalizing`, or `closed`.                                                                                                                                    |
| `closed_at`    | `null` until closed, then the final snapshot timestamp.                                                                                                               |

Admission reserves the **estimated sell cost** before a call. Real settled cost can exceed the cap by the estimation error of calls in flight. Keep a margin in your own authorization hold; do not release it based on open-key spend or on an LLM response finishing. An unknown live value is not zero.

### Read per-call receipts

```bash theme={null}
curl --fail-with-body -sS --get \
  "https://api.sunra.ai/v1/budget-keys/$ID/calls" \
  -H "Authorization: Key $SUNRA_KEY" \
  --data-urlencode 'limit=100'
```

`limit` defaults to 100 and accepts 1–500. While `has_more` is true, request the next page with `after` set to `next_cursor`, which is the last row's `id`. Pass it unchanged and URL-encode it. An invalid cursor or one belonging to another child returns `400 invalid_input`.

An illustrative page after closing a key with one paid call:

```json theme={null}
{
  "object": "list",
  "data": [
    {
      "id": "chatcmpl_example",
      "model": "google/gemini-2.5-flash",
      "status": "PAID",
      "cost_usd": "0.00012000",
      "created_at": "2026-09-11T12:00:00.000Z",
      "completed_at": "2026-09-11T12:00:01.000Z",
      "usage": {"input_tokens": 10, "output_tokens": 20},
      "tag": "summary"
    }
  ],
  "has_more": false,
  "next_cursor": null,
  "receipt_version": 1,
  "calls_count": 1,
  "complete": true
}
```

Rows include `PAID`, `VOID`, `PENDING`, and `UNCOLLECTIBLE` attempts. Only `PAID` rows have a settled cost; every other status has `cost_usd: "0.00000000"`. A refusal before a call record is created is not counted. `completed_at` and `tag` can be `null`; `usage` can include cache token counts, and `wire_id` is optional.

`calls_count` counts all attempts in the receipt across all pages, not just the current page. **`complete` is true only for a closed key**; open and finalizing receipts can change. For final reconciliation, close the key, fetch every receipt page from the beginning, and sum `cost_usd` for `status: "PAID"` using decimal arithmetic. That sum equals the closed `spent_usd`, and the total number of rows equals the closed `calls` and receipt `calls_count`. `has_more: false` alone does not mean the key is closed.

## Close the key

```bash theme={null}
curl --fail-with-body -sS -i -X POST \
  "https://api.sunra.ai/v1/budget-keys/$ID/close" \
  -H "Authorization: Key $SUNRA_KEY"
```

Close takes no body and stops new calls. Calls with the closed child fail with `401 budget_key_revoked`. Already admitted calls are not canceled by close and can still settle.

| Response                          | What to do                                                                                                                    |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `200` with `status: "closed"`     | Use `spent_usd` as the final amount. The snapshot never changes afterwards.                                                   |
| `202` with `status: "finalizing"` | Calls are still in flight or finalization is unfinished. Keep the authorization hold and poll close again until `200 closed`. |
| `503 budget_store_unavailable`    | Closing has not been confirmed successfully. Keep the hold and retry with backoff.                                            |

Closing is idempotent. Repeating it returns the same final snapshot once closed, including a zero-spend snapshot for a key with no calls. You can also poll GET by ID, but GET returns HTTP `200` even while `status` is `finalizing`: always check the body. There is no fixed guarantee for how quickly finalization completes.

## End-to-end example

Run this in Bash with `curl` (supporting `--fail-with-body`) and `jq`. Set `SUNRA_KEY` to your ordinary key as described in [Authentication](/platform/authentication). The example uses `google/gemini-2.5-flash`; your parent key must allow that model. It mints a \$0.05 key for one hour, makes one call, reads its receipt, closes the key, and prints final spend. Receipt values read before close may still be pending.

The exit handler also attempts to close the key if the call or receipt read fails. Save the printed `ref` and child ID in your application so interrupted work can be recovered.

```bash theme={null}
set -euo pipefail
: "${SUNRA_KEY:?Set SUNRA_KEY to an active ordinary Sunra API key}"
WORK_DIR=$(mktemp -d)
ID=''

close_key() {
  while true; do
    if ! HTTP_STATUS=$(curl -sS -o "$WORK_DIR/close.json" -w '%{http_code}' \
      -X POST "https://api.sunra.ai/v1/budget-keys/$ID/close" \
      -H "Authorization: Key $SUNRA_KEY"); then
      sleep 2
      continue
    fi
    case "$HTTP_STATUS" in
      200)
        jq -e '.status == "closed"' "$WORK_DIR/close.json" >/dev/null || return 1
        jq -r '"Final spend (USD): " + .spent_usd' "$WORK_DIR/close.json"
        return 0
        ;;
      202) sleep 2 ;;
      503)
        jq -e '.error.details.retryable == true' "$WORK_DIR/close.json" >/dev/null || return 1
        sleep 2
        ;;
      *) cat "$WORK_DIR/close.json" >&2; return 1 ;;
    esac
  done
}

cleanup() {
  RESULT=$?
  trap - EXIT
  if [[ -n "$ID" ]]; then
    close_key || RESULT=1
  fi
  rm -rf "$WORK_DIR"
  exit "$RESULT"
}
trap cleanup EXIT

REF="turn-$(jq -nr 'now * 1000000 | floor')"
printf 'Recovery ref: %s\n' "$REF"
EXPIRES_AT=$(jq -nr 'now + 3600 | strftime("%Y-%m-%dT%H:%M:%SZ")')
MINT=$(curl --fail-with-body -sS https://api.sunra.ai/v1/budget-keys \
  -H "Authorization: Key $SUNRA_KEY" \
  -H 'Content-Type: application/json' \
  -d "$(jq -n --arg expires "$EXPIRES_AT" --arg ref "$REF" '{
    cap_usd: "0.05",
    expires_at: $expires,
    ref: $ref,
    metadata: {allowed_tags: ["summary"]},
    models: ["google/gemini-2.5-flash"]
  }')")
ID=$(jq -er '.id' <<<"$MINT")
printf 'Child ID: %s\n' "$ID"
CHILD=$(jq -er '.secret_key' <<<"$MINT")
unset MINT

curl --fail-with-body -sS -D "$WORK_DIR/chat.headers" \
  -o "$WORK_DIR/chat.json" https://api-llm.sunra.ai/v1/chat/completions \
  -H "Authorization: Bearer $CHILD" \
  -H 'Content-Type: application/json' \
  -H 'x-sunra-call-tag: summary' \
  -d '{
    "model": "google/gemini-2.5-flash",
    "messages": [{"role": "user", "content": "Say hello in one sentence."}],
    "max_tokens": 128,
    "n": 1
  }'
CALL_ID=$(awk 'tolower($1) == "x-sunra-prediction-id:" {gsub("\r", "", $2); print $2}' "$WORK_DIR/chat.headers")
printf 'x-sunra-prediction-id: %s\n' "$CALL_ID"

curl --fail-with-body -sS "https://api.sunra.ai/v1/budget-keys/$ID/calls?limit=100" \
  -H "Authorization: Key $SUNRA_KEY" \
  | jq --arg id "$CALL_ID" '.data[] | select(.id == $id)'

close_key
ID=''
unset CHILD
```

Do not automatically retry mint after an ambiguous failure. For your application's final per-call accounting, re-read all receipt pages after `closed`, as described above. Repeating an LLM request is not deduplicated and can incur another charge.

## Recovering a lost ticket

Use your saved `ref` to find child IDs when a mint response or your local ticket record was lost:

```bash theme={null}
curl --fail-with-body -sS --get https://api.sunra.ai/v1/budget-keys \
  -H "Authorization: Key $SUNRA_KEY" \
  --data-urlencode 'ref=turn-123' \
  --data-urlencode 'limit=100'
```

The response is `{ "data": [...], "has_more": false, "next_cursor": null }`, with budget-key resources in `data` and no secrets. `ref` is required, nonempty, and at most 256 characters. `limit` defaults to 100 and accepts 1–100. If `has_more` is true, pass the opaque `next_cursor` as URL-encoded `after` to get the next page. Multiple keys can share the same `ref`; inspect all matches and close orphaned keys.

List is for recovery. Use GET by ID or close to obtain the final accounting state; do not settle your authorization hold from an open list result. The secret is never recoverable.

**Any active ordinary key of the same organization can get, list, close, and read the calls of a budget key.** It need not be the key that minted it. After rotating or revoking the parent key, use another active ordinary key in that organization to finish outstanding tickets. Revoking the original parent stops new child calls with `401 budget_key_revoked`, but does not finish the child's final accounting for you.

## Errors

Read the HTTP status and `error.details.reason`; the following table covers budget-specific handling. Existing model-access, parameter-validation, organization-balance, and upstream errors can retain their usual response shapes.

| HTTP | `reason`                     | What to do                                                                                                                                                                                 |
| ---- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400  | `max_tokens_required`        | Supply the appropriate output limit. Chat requires `max_tokens` or `max_completion_tokens`.                                                                                                |
| 400  | `invalid_input`              | Fix field types, limits, model restrictions, tag format, cursor, or unsupported output parameters before retrying.                                                                         |
| 401  | `budget_key_expired`         | Stop using this child; close it for final accounting. Mint a new key only for a new authorization.                                                                                         |
| 401  | `budget_key_revoked`         | Stop using this child. Close or query it with an active ordinary key in the same organization.                                                                                             |
| 402  | `budget_exhausted`           | This call's estimate exceeds the remaining budget even without other reservations. Reduce the request's estimated cost or obtain a new authorization; retrying it unchanged will not help. |
| 402  | `budget_reserved`            | Retryable. Other calls temporarily reserve the needed budget. Wait for them to settle, then retry with backoff.                                                                            |
| 402  | `budget_state_unknown`       | Stop using this child; close it and wait for final accounting. Do not infer available budget from missing figures. Any further work needs a newly authorized key.                          |
| 403  | `budget_key_route_forbidden` | Use an ordinary key for management; use the child only on supported LLM routes.                                                                                                            |
| 403  | `budget_call_tag_forbidden`  | Supply `x-sunra-call-tag` with a value from the key's `metadata.allowed_tags`.                                                                                                             |
| 404  | `budget_key_not_found`       | Check the child ID and the organization of the ordinary key used for management.                                                                                                           |
| 503  | `budget_store_unavailable`   | Retryable. Retry with backoff and preserve the authorization hold until closed. For an ambiguous mint failure, recover by `ref` before minting again.                                      |

Example `402 budget_reserved` body:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "INSUFFICIENT_CREDIT",
    "message": "budget_reserved",
    "details": {
      "code": "INSUFFICIENT_CREDIT",
      "message": "budget_reserved",
      "reason": "budget_reserved",
      "retryable": true,
      "spent_usd": "0.00000000",
      "reserved_usd": "0.04000000",
      "cap_usd": "0.05000000",
      "estimate_usd": "0.02000000"
    }
  }
}
```

`budget_exhausted` and `budget_reserved` include all four USD strings from the admission decision. `budget_state_unknown` includes only `cap_usd` among these amounts; missing spend and reservation values must not be treated as zero.

## Limits and guarantees

* **Lifetime:** at most 24 hours from mint validation. Expiry stops new calls; you still need to obtain a closed snapshot for final accounting.
* **Best-effort cap:** admission reserves estimated sell cost. Actual spend can exceed `cap_usd` by estimation error across calls in flight. Keep a margin in your own authorization hold.
* **Live versus final:** open and finalizing figures can change. Only `status: closed` provides the authoritative, immutable snapshot; `complete: true` marks its receipt. The snapshot covers recorded settlement, so settlement that is never recorded or arrives after the snapshot can be absent from the final amount.
* **Billing:** the organization that owns the parent key is billed exactly as before, with its existing balance and access restrictions. A budget key does not fund a wallet or transfer your end user's money into Sunra; your application manages end-user authorization and accounting.
