Skip to main content
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.

Media endpoints

When media access is enabled, pool child keys can call all multimodal queue endpoints and initiate uploads, subject to their model access restrictions. Use https://api.sunra.ai with Authorization: Bearer <child secret>: Standalone budget keys cannot use these routes (403 budget_key_route_forbidden). Upload initiation is unbilled and returns 201; use its signed upload URL to transfer your file. Each media submission reserves the same amount used for organization credit: the exact price for pre_defined endpoints, or the price card’s freeze_amount for post_calculated endpoints. For example, seed-asr-2.0 reserves 1.00,seedance−2.51.00, seedance-2.5 18–21 depending on the endpoint, and seedance-2.0 8.Settlementrecordsactualcharges,whichcanexceedtheestimate.Thisisestimatedadmission,notahardspendingceiling:iftotalcommittedandreservedcostsexceedpoolauthorization,thepoolpausesfor‘liability‘;newsubmissionsreceive‘402budgetpoolpaused‘.Addauthorizationwith‘credit‘,then‘resume‘.Anoptionalchild‘capusd‘alsolimitsestimatedadmission:achildwitha8. Settlement records actual charges, which can exceed the estimate. This is estimated admission, not a hard spending ceiling: if total committed and reserved costs exceed pool authorization, the pool pauses for `liability`; new submissions receive `402 budget_pool_paused`. Add authorization with `credit`, then `resume`. An optional child `cap_usd` also limits estimated admission: a child with a 1 cap can reserve 1andsettle1 and settle 2 while a sufficiently funded pool remains active. The child’s spent_usd records $2 and its next submission is denied. The estimate_usd in a 402 admission response gives the amount required for that submission. N concurrent submissions at the same price need N times that amount in available pool authorization before settlement releases reservations; mixed endpoints need the sum of their admission amounts. A 0.10poolcannotadmita0.10 pool cannot admit a 1.00 audio reservation, and N concurrent 21videosubmissionsneedN×21 video submissions need N × 21 available. Lower actual costs release the difference. Failed calls with a captured charge retain that charge even when the invoice is VOID. x-sunra-call-tag is validated only when submitting a prediction. A key restricted by metadata.allowed_tags must send an allowed tag there; upload, status, result, stream, and cancel do not require a tag. A child can read or cancel only its own requests; another child’s or a missing request returns 404, including the stream endpoint. Closing or revoking a child does not cancel in-flight work. Pool child close returns HTTP 200 with status: "finalizing" until invoices settle, then closed; final totals include late media charges. /calls lists LLM rows first, followed by media rows, each ordered by its database ID. The cursor is a call ID (chatcmpl_ or pd_). Open listings are not snapshots: after a cursor enters the media segment, newly inserted LLM rows are absent from that traversal. For the complete ledger, start a fresh listing after close and require complete: true; calls_count counts both collections. Media receipts include endpoint, non-token usage: { usage_key, usage, unit_amount }, the submission tag, and actual cost_usd; they have no wire_id. Missing media usage fields are explicitly null: canceled or charged-VOID calls without usage return usage: { usage_key: null, usage: null, unit_amount: null }, including after close. Media receipts never contain token counters.

Mint a budget key

Send POST /v1/budget-keys with a JSON body: 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:
A 201 response is the resource itself, with no data wrapper. Example values:
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 supported LLM endpoints: Apart from the pool-only media routes above, 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. For admission, each image, audio, video, or document part reserves an allowance of 8,192 tokens. If the model prices that media kind separately, the allowance uses its dedicated input rate; otherwise it is folded into prompt_tokens and priced at the prompt/input rate. This is an estimation convention, not a provider-enforced bound. Settlement uses actual usage. Text is estimated conservatively from the serialized request (including tools and system), so text-only calls almost always settle below their reservation. Settled cost can exceed the reservation in three known cases, and you should size your hold margin around whichever apply: prompt-cache writes on Anthropic-format requests bill the cached prefix at the cache-write rate (1.25× the input rate, 2× for a 1-hour ttl) and the reservation does not include that premium (Claude models with tool search may write a cache prefix without an explicit cache_control); a media part that exceeds the 8,192-token allowance, typical for long audio, video, or multi-page documents; and xAI Grok models, whose reasoning tokens are not bounded by max_tokens — a call to those has no reliable upper bound, so exclude them with models if the cap must hold. 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.

Reasoning budgets stay under the output ceiling

thinking and reasoning, when present, must be objects. thinking.type must be exactly "enabled" or "disabled"; adaptive, unknown, missing, and non-string types are rejected for every child key. Enabled thinking requires thinking.budget_tokens. Every supplied thinking.budget_tokens or reasoning.max_tokens must be a positive safe integer strictly below the admitted output ceiling; a budget equal to or above the ceiling, or a malformed one (null, string, fraction, zero, negative, unsafe integer), returns 400 invalid_input before any reservation, credit freeze, call record, or upstream request. Malformed values are never treated as omitted. Accepted budgets are forwarded unchanged. For Claude models (including aliases) and Messages requests, the comparison uses at least the provider’s 1,024-token minimum reasoning allocation, so a 512-token thinking budget is treated as 1,024. For example, max_tokens: 64 with thinking: {"type":"enabled","budget_tokens":1024} returns 400 invalid_input (thinking.budget_tokens must be below the output ceiling); to use that budget, send an output ceiling of at least 1,025 and fund the child accordingly. Effort-style controls (reasoning_effort, reasoning.effort, reasoning: {"enabled": true}) remain supported and stay inside the ceiling; thinking: {"type":"disabled"} remains supported, including the Qwen translation below. Ordinary keys and sessions are unchanged. 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:
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.

Turn thinking off for Qwen Chat

For child-key Chat Completions on the mapped Qwen → DashScope routes, use thinking: {"type":"disabled"} or reasoning_effort: "none", or both. Sunra consumes these controls and sends the native off switch, preserving your output ceiling and n. The child must allow the chosen model and tag.
The supported public IDs are qwen/qwen3.7-flash, qwen/qwen3.7-plus, qwen/qwen3.7-max, and qwen/qwen3.8-max, each resolving through LiteLLM to the same-named DashScope group (without the qwen/ prefix). Support depends on the resolved route; choosing a model name or a provider preference alone does not establish support. These cases return 400 invalid_input before a call record, credit freeze, or budget reservation:
  • An off intent combined with reasoning_effort other than "none", thinking other than exactly {"type":"disabled"}, or any reasoning field.
  • Extra keys in the disabled thinking object, including budget_tokens.
  • An off intent on an unmapped Qwen route, or on Qwen Messages or Responses.
  • Native enable_thinking (including literal false) or client extra_body: both remain rejected for child keys.
Ordinary-key behavior is unchanged; ordinary requests do not receive this child-only translation. Non-Qwen routes and requests without off intent retain their existing behavior. This is not a universal guarantee across providers. HTTP 200 alone does not prove thinking was off. Verification needs observed usage, including an explicit usage.reasoning_tokens: 0; an absent field means unknown.

Track spend

Read the key

With ID set to the minted child id:
The response has the same resource fields as mint, without secret_key: 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

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:
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; wire_id is optional. usage always contains four integer counts: input_tokens, output_tokens, cache_read_tokens, and cache_write_tokens, with 0 when not applicable. When invoice.price_breakdown exists, these counts come from its billing buckets: input_tokens is uncached input only, and the three input buckets are mutually exclusive, so total prompt tokens = input_tokens + cache_read_tokens + cache_write_tokens. cache_write_tokens combines the 5-minute and 1-hour cache-write tiers; output_tokens is the billed output count. Each row’s usage_exclusive tells you whether the input buckets are mutually exclusive: it is true when usage comes from price_breakdown or a valid persisted route dialect stamp lets the receipt separate the raw counts. Without price_breakdown, the receipt reads flat or nested raw cache counts on a best-effort basis and subtracts only the buckets the dialect marks as included in input, clamping the result at 0. If the dialect stamp is missing or malformed, the raw input count is preserved and usage_exclusive is false; do not assume the sum above holds. Live rows use live data; closed rows use only the sealed invoice, usage, and dialect stamp. Historical sealed invoices can be displayed as v2 without a migration. PAID rows with price_breakdown also include cost_breakdown, with exactly four USD strings at 8 decimal places: input_usd, cache_read_usd, cache_write_usd, and output_usd. Using decimal arithmetic, they sum exactly to cost_usd. Each bucket is calculated as exact usage × unit price, combining 5-minute and 1-hour write costs before rounding to 8 decimal places with ROUND_HALF_EVEN. After rounding, cost_usd − sum(rounded buckets) is added to the bucket with the largest exact cost; ties use input, cache read, cache write, then output order. The same rule reconciles to the amount actually paid when a refund makes amount_paid differ from the invoice total. cost_breakdown is absent for non-PAID rows and for legacy PAID rows without price_breakdown. 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. usage.reasoning_tokens is an optional non-negative integer observed from upstream usage. It is always spelled this one way in the receipt, whichever name the gateway response used: completion_tokens_details.reasoning_tokens on Chat Completions, output_tokens_details.reasoning_tokens on Responses, and output_tokens_details.thinking_tokens on Anthropic-shaped /v1/messages responses (Claude). An explicit 0 means upstream reported zero reasoning tokens; absence means upstream reported no count at all, which is unknown, not zero. Positive counts remain output-token detail and are not added again to output tokens or invoices. Live rows use live usage; closed rows use sealed usage only, even if live usage changes later. Historical sealed counts can be exposed when already stored. receipt_version is 2.

Watch keys in the dashboard

The Budget tab on platform.sunra.ai/dashboard/budget lists every budget key in your organization under Keys, whatever key minted it and regardless of management. Filter by search (ref or name), state, and parent key; open a row to see its per-call receipts. The tab is read-only: minting and closing stay with the parent key on the API. Open keys show the live values described above when available; closed keys show the immutable final spend and call count. Budget keys never appear on the API tokens page.

Close the 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. 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. For standalone keys, GET by ID and close reconcile outstanding holds against persisted settlements, so normal finalization converges once every call’s settlement is recorded. If a settlement never arrives, its hold expires 30 minutes after admission. If live state is lost, finalization waits until expires_at plus 30 minutes; unknown live figures remain null, not zero. Sealing is lazy: continue polling GET or close after these conditions are met. Storage failures or receipt batching can require further retries.

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. 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.
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:
The response is { "data": [...], "has_more": false, "next_cursor": null }, with budget-key resources in data and no secrets. ref, parent_api_key_id, or closed_by_api_key_id is required: supply at least one selector. When supplied, ref must be nonempty and at most 256 characters; parent_api_key_id is an exact string of 1–128 characters, without trimming or case conversion. Supplied selectors mean AND. An unknown or foreign-org parent ID returns an empty list and grants no authority. 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. List visible children by issuing parent without a ref:
By default, lists order by creation time descending, then internal ID descending, and apply authorization before pagination. has_more reflects visible rows only. Valid legacy ref cursors continue to work. The cursor anchor must match the current organization, selectors, and visibility; a mismatch returns 400 invalid_input and requires restarting the scan. Cursors are neither snapshots nor credentials.

Management visibility and recovery

Every budget resource response (mint, get, each list item, and close) includes effective management. The mode governs resource reads, call receipts, close, and list membership: Recovery uses another active ordinary key in the original organization; no new admin key is required. A failed parent lookup returns retryable 503 budget_store_unavailable, never recovery access. Reactivating the parent restricts subsequent requests again. Revoking the parent stops new child calls but does not cancel admitted work or seal receipts automatically. This narrows budget-management authorization; it is not tenancy. Same-org ordinary keys can still see child calls and costs through /v1/completions. A separate reconciler key cannot read parent_only receipts while the parent is active: use the issuing parent’s credential or choose org mode. Use separate organizations when you need tenant isolation. Minting parent_only is enabled per rollout. Before enablement it returns 400 budget_key_parent_only_disabled; never retry by dropping management. Filters and effective mode output are unconditional. Operators must finish upgrading all auth/apiv1 management instances and verify indexes before setting BUDGET_KEYS_PARENT_ONLY_ENABLED=true in auth. Once protected records exist, rollback must retain their authorization checks.

What did this key do?

Use four source lists independently; there is no composite /activity endpoint. Authenticate management reads with an active ordinary key in the same organization. Budget lists require at least one of ref, parent_api_key_id, or closed_by_api_key_id; supplied selectors combine with AND. The closer selector has the same exact-string, 1–128-character rules as the parent selector. Unknown or foreign selector IDs produce an empty list. Optional time_range_start and time_range_end are inclusive ISO datetimes with a timezone (for example 2026-09-15T12:00:00Z). Either bound may be omitted; start must not exceed end. time_field is created_at (default), close_requested_at, or closed_at. Rows missing the selected timestamp are excluded. Ordering is selected time descending, then internal ID descending. Invalid selectors, ranges, time fields, or cursors return 400 invalid_input. Every budget resource includes created_at, close_requested_at, and closed_by_api_key_id, with unknown values represented by null. closed_at is final receipt sealing time. The first explicit POST /close accepted before sealing records its authenticated caller and server timestamp atomically with durable admission revocation, even if the request later returns 503. Retries, another closer, and finalization-lock takeover cannot replace that attribution. Lazy GET, calls-triggered finalization, and mint cleanup leave it null unless an explicit close was accepted before sealing. Historical nulls mean unknown; do not infer the parent was the closer. The revocations response is { data: [...], has_more: false }. Each entry contains id, object: "api_key_revocation", api_key_id, revoked_at, revoked_by, and nullable reactivated_at. It returns at most the latest 50 entries, by construction, with no cursor. The subject may already be revoked; any same-org active ordinary caller can read it. Unknown or foreign subjects return 404 api_key_not_found; budget-child subjects return 400 invalid_input; child/session callers are forbidden. Dependency failures return 503 budget_store_unavailable, never a fabricated empty history. Actors are {type: "api_key" | "user" | "system" | "unknown", id: string | null}. API-key/user actors identify the actual acting principal; member-JWT revocations record the member as user, backend/internal revocations use system with null ID, and unknown actors use unknown with null ID. The creator is never substituted. Repeated revokes while already revoked add nothing. Reactivation stamps the last open entry; a later revoke adds another. Existing member-authorized key detail/list responses also expose latest revoked_at / revoked_by summaries (retained after reactivation) and revocation_history. Historical revoked keys are not backfilled: null summaries and an empty history do not prove the key was never revoked. Older entries beyond 50 are no longer retained; there is no history-completeness marker. Keep each source’s cursor convention:
  • Budget lists use opaque after=next_cursor (maximum 256 characters), limit 1–100, default 100. Creation-time pages retain the existing cursor and validate its anchor against current filters and visibility. Other time fields use versioned cursors bound to the organization, caller, selectors, range, and time field; changing any requires restarting at page one. A mismatch returns 400. Pages are not snapshots.
  • Completions retain starting_after (the last row ID); ending_before is rejected. They sort by internal ID descending, with inclusive creation-time range filters and default limit 10 / maximum 10000; do not pass a budget cursor to them.
  • Child /calls receipts use the exact last completion ID as after, limit 1–500, default 100.
  • Revocations return the retained array in one response and accept no pagination cursor.
Overlap inclusive time windows and deduplicate children/completions/events by their stable IDs. Track outstanding child IDs and poll GET until closed: creation-time scans cannot reveal later sealing, and close-request scans do not reveal when a final receipt becomes ready. A parent scan with time_field=closed_at can supplement polling. Re-read unsettled own completions by ID; their original creation time does not change at settlement. Final receipts remain the settlement authority: after the child closes, fetch every /calls page from the beginning, require complete:true, and reconcile the PAID-only decimal sum and all-attempt count against final spent_usd and calls. Activity lists are observations, not another ledger. Do not add child receipts to totals that already include those same child completions.

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. Example 402 budget_reserved body:
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.