---
title: "Reserve budget for a planned action (concurrency-safe)"
method: POST
path: "/v1/reservations"
tags: ["Reservations"]
---

# Reserve budget for a planned action (concurrency-safe)

`POST /v1/reservations`

## Request

- Base URL: `https://api.cycles.local`
- URL: `https://api.cycles.local/v1/reservations`
- Auth: API key in header `X-Cycles-API-Key`

Atomically reserves the estimated amount across server-derived scopes and returns a reservation_id. Reservations expire at expires_at_ms; commits are accepted through (expires_at_ms + grace_period_ms). 
If dry_run=true, server MUST evaluate the full reservation request and return decision/caps/affected_scopes/balances as if the reservation were live, but MUST NOT modify balances, persist a reservation, or require commit/release.
DRY-RUN RESPONSE RULES (NORMATIVE): - reservation_id and expires_at_ms MUST be absent. - affected_scopes MUST be populated regardless of decision outcome (ALLOW / ALLOW_WITH_CAPS / DENY). - If decision=ALLOW_WITH_CAPS, caps MUST be present; otherwise caps MUST be absent. - If decision=DENY, reason_code SHOULD be populated; it is the primary diagnostic signal for why the dry_run was denied. - If the owning tenant's status is CLOSED (deployments with a governance plane),
  dry_run MUST NOT return 409 TENANT_CLOSED; a fresh (non-replay) evaluation MUST
  return decision=DENY with reason_code=TENANT_CLOSED, reflecting as-if-live the
  409 the persisting create returns (same-key replays of pre-close evaluations
  follow the IDEMPOTENCY rule below). A tenant record whose status cannot be determined (malformed record)
  MUST fail closed with 500 INTERNAL_ERROR even on dry_run — see the closed-tenant
  binding in ERROR SEMANTICS.
- balances MAY be populated (recommended for operator visibility), but MUST reflect a non-mutating evaluation.
OVER-LIMIT BLOCKING (NORMATIVE): - If ANY affected scope has debt > overdraft_limit (is_over_limit=true), the reservation MUST be rejected 
  with 409 OVERDRAFT_LIMIT_EXCEEDED, regardless of available remaining budget.
- This blocks new work when overdraft reconciliation is needed.
IDEMPOTENCY (NORMATIVE): - On replay with the same idempotency_key, the server MUST return the original successful response
  outcome and payload, including the original reservation_id (if any), except that remaining_ttl_ms is a
  volatile transport observation not included in the attested CyclesEvidence payload. When emitted, it
  MUST be recomputed from the original expires_at_ms and current authoritative server time while
  constructing the replay response, MUST NOT be copied from the stored response, and MUST be 0 if the
  reservation is no longer ACTIVE. All other fields replay verbatim.

TENANCY (NORMATIVE): - subject.tenant MUST match the effective tenant derived from auth; otherwise the server MUST return 403 FORBIDDEN.

## Headers

- `X-Idempotency-Key` string

## Request body

- object
  - `idempotency_key` string, required
  - `subject` union, required: Dimension bag for hierarchical budgets. At least one standard field (tenant, workspace, app, workflow, agent, or toolset) MUST be provided. A subject containing only `dimensions` is invalid; server MUST return 400 INVALID_REQUEST. Hierarchy: tenant → workspace → app → workflow → agent → toolset. CHARSET (NORMATIVE, revision 2026-07-03): - Standard-field values (tenant/workspace/app/workflow/agent/toolset) SHOULD match ^[a-zA-Z0-9_.-]+$. Canonical scope identifiers and scope paths use ":" and "/" as structural delimiters, so delimiter, whitespace, and control characters have no stable canonical encoding. - Servers MAY reject standard-field values outside this pattern with 400 INVALID_REQUEST (the reference implementation does). Clients MUST NOT rely on out-of-pattern values being accepted, and portable clients SHOULD restrict themselves to the pattern above. EXTENSIBILITY (v0): - dimensions is an optional, user-defined map for alternative taxonomies (e.g., cost_center/department/project) and policy/reporting. - v0 servers MAY ignore dimensions for budgeting decisions; if they do, they still MUST accept and round-trip it. - Keys SHOULD be lowercase and match ^[a-z0-9_.-]+$ for stable canonicalization; values are opaque strings.
    - object
      - `tenant` string, required
      - `workspace` string
      - `app` string
      - `workflow` string
      - `agent` string
      - `toolset` string
      - `dimensions` object: Optional custom dimensions for enterprise taxonomies and policy/reporting.
    - object
      - `tenant` string
      - `workspace` string, required
      - `app` string
      - `workflow` string
      - `agent` string
      - `toolset` string
      - `dimensions` object: Optional custom dimensions for enterprise taxonomies and policy/reporting.
    - object
      - `tenant` string
      - `workspace` string
      - `app` string, required
      - `workflow` string
      - `agent` string
      - `toolset` string
      - `dimensions` object: Optional custom dimensions for enterprise taxonomies and policy/reporting.
    - object
      - `tenant` string
      - `workspace` string
      - `app` string
      - `workflow` string, required
      - `agent` string
      - `toolset` string
      - `dimensions` object: Optional custom dimensions for enterprise taxonomies and policy/reporting.
    - object
      - `tenant` string
      - `workspace` string
      - `app` string
      - `workflow` string
      - `agent` string, required
      - `toolset` string
      - `dimensions` object: Optional custom dimensions for enterprise taxonomies and policy/reporting.
    - object
      - `tenant` string
      - `workspace` string
      - `app` string
      - `workflow` string
      - `agent` string
      - `toolset` string, required
      - `dimensions` object: Optional custom dimensions for enterprise taxonomies and policy/reporting.
  - `action` object, required
    - `kind` string, required: Action type identifier. Recommended format: <category>.<operation> Examples: llm.completion, llm.embedding, tool.search, tool.calculator, db.query, db.write, http.get, http.post, file.upload, message.email, webhook.outbound
    - `name` string, required: Provider/model/tool identifier (e.g., "openai:gpt-4o-mini", "web.search")
    - `tags` string[]: Optional policy tags (e.g., ["prod","customer-facing"])
  - `estimate` object, required
    - `unit` 'USD_MICROCENTS' | 'TOKENS' | 'CREDITS' | 'RISK_POINTS', required: Standard units. USD_MICROCENTS preserves precision for per-call and batched accounting (int64). - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars - 1 USD = 100 cents = 10^8 USD_MICROCENTS - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B TOKENS are integer token counts. CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations).
    - `amount` integer, required
  - `ttl_ms` integer
  - `grace_period_ms` integer: Grace window after TTL for in-flight commits. Default 5s. OPERATIONAL GUIDANCE: - For high-latency actions (streaming LLM, slow APIs), consider setting higher (10-30s). INTEROP / SAFETY RATIONALE: - The 60s maximum is an interoperability ceiling to limit long-lived "in-flight" commit windows and reduce zombie-reservation and abuse surface (grace extends the time that a crashed client can keep budget locked). LONG-RUNNING WORKFLOWS: - For multi-minute operations, clients SHOULD keep ttl_ms relatively short and use /v1/reservations/{reservation_id}/extend as a heartbeat, and/or use chunked reserve/commit cycles rather than relying on large grace_period_ms.
  - `overage_policy` 'REJECT' | 'ALLOW_IF_AVAILABLE' | 'ALLOW_WITH_OVERDRAFT': How server handles commits where actual > reserved. REJECT: reject commit (client must reserve a buffer or over-estimate). Recommended: add 10-20% buffer to estimates when using REJECT. If the action already happened externally, this creates an unaccounted gap. ALLOW_IF_AVAILABLE (default): commit always succeeds when actual > reserved. If remaining budget supports the full delta, charge it atomically across all derived scopes. Otherwise, cap the delta to available remaining (minimum across all affected scopes, floor 0), charge estimate + capped_delta, and set is_over_limit=true on scopes where the full delta could not be covered. This blocks future reservations until reconciled. Never creates debt. Never rejects a commit — the action already happened. ALLOW_WITH_OVERDRAFT: if remaining budget supports the delta, commit normally. Otherwise, check if (debt + delta) <= overdraft_limit across all affected scopes. If yes: commit succeeds, add delta to debt, remaining can go negative. If no: commit fails with 409 OVERDRAFT_LIMIT_EXCEEDED. This policy ensures the ledger always reflects reality even when budget is exhausted at commit time. CONCURRENCY (NORMATIVE): - The overdraft_limit check is performed per-commit and is NOT atomic across concurrent commits to the same scope. - Multiple commits may each individually pass the (debt + delta) <= limit check but collectively cause debt to exceed overdraft_limit. - This is acceptable: all commits succeed (actions already happened), and the scope enters "over-limit" state, blocking future reservations until reconciled. - Servers SHOULD use optimistic locking or compare-and-swap on debt updates to ensure individual commit atomicity, but cross-commit atomicity is not required.
  - `dry_run` boolean: Shadow-mode evaluation. If true, the server MUST evaluate the reservation request and return decision/caps/affected_scopes as if it would reserve, but MUST NOT modify balances, persist a reservation, or require commit/release. Intended for safe rollout and testing of full reserve-path logic.
  - `metadata` object

## Response `200`

Reservation decision (ALLOW/DENY with optional caps)

- object
  - `decision` 'ALLOW' | 'ALLOW_WITH_CAPS' | 'DENY', required
  - `reservation_id` string: Present if decision is ALLOW or ALLOW_WITH_CAPS and dry_run is false. MUST be absent when dry_run is true.
  - `reserved` object
    - `unit` 'USD_MICROCENTS' | 'TOKENS' | 'CREDITS' | 'RISK_POINTS', required: Standard units. USD_MICROCENTS preserves precision for per-call and batched accounting (int64). - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars - 1 USD = 100 cents = 10^8 USD_MICROCENTS - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B TOKENS are integer token counts. CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations).
    - `amount` integer, required
  - `expires_at_ms` integer
  - `remaining_ttl_ms` integer: Observation of remaining reservation lifetime in milliseconds while this response payload is constructed, computed as max(0, expires_at_ms - current authoritative server time) using the same authoritative clock used for reservation expiry decisions. Servers SHOULD emit it on successful responses that return a live reservation; it MUST be absent on dry-run and DENY responses. This field is volatile transport metadata and is not part of the attested CyclesEvidence payload. On a same-key idempotent replay, a server that emits it MUST recompute it at replay-response construction time from the original expires_at_ms and MUST NOT copy its originally stored value; it MUST be 0 if the reservation is no longer ACTIVE and MAY conservatively understate current lead if a later, separately keyed extension moved expiry outward. All other fields replay verbatim. Added in 0.1.25.16 (additive); clients MUST treat it as optional for backward compatibility. When present it is the NORMATIVE input to heartbeat scheduling — see HEARTBEAT GUIDANCE on extendReservation.
  - `scope_path` string: Canonical scope path (server-derived)
  - `affected_scopes` string[], required: Canonical scope identifiers impacted by this reservation, in canonical order.
  - `caps` object: Optional soft-landing constraints returned by /decide or reservation ALLOW_WITH_CAPS. v0 intentionally keeps caps simple and concrete (no condition language). PRECEDENCE RULES: - If tool_allowlist is non-empty, ONLY those tools are allowed (denylist ignored). - Otherwise, if tool_denylist is non-empty, all tools EXCEPT those are allowed. - If both are empty/null, no tool restrictions apply. - Tool names are case-sensitive and match Action.name exactly.
    - `max_tokens` integer
    - `max_steps_remaining` integer
    - `tool_allowlist` string[]
    - `tool_denylist` string[]
    - `cooldown_ms` integer
  - `balances` object[]
    - `scope` string, required: Canonical scope identifier (server-derived), e.g. "tenant:t1", "workflow:run123"
    - `scope_path` string, required: Canonical hierarchical path (server-derived)
    - `remaining` object, required: Like Amount, but allows negative values. Used for Balance.remaining which can be negative in overdraft scenarios.
      - `unit` 'USD_MICROCENTS' | 'TOKENS' | 'CREDITS' | 'RISK_POINTS', required: Standard units. USD_MICROCENTS preserves precision for per-call and batched accounting (int64). - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars - 1 USD = 100 cents = 10^8 USD_MICROCENTS - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B TOKENS are integer token counts. CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations).
      - `amount` integer, required: Signed integer amount. Can be negative when representing remaining balance in overdraft state (debt > allocated - spent - reserved).
    - `reserved` object
      - `unit` 'USD_MICROCENTS' | 'TOKENS' | 'CREDITS' | 'RISK_POINTS', required: Standard units. USD_MICROCENTS preserves precision for per-call and batched accounting (int64). - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars - 1 USD = 100 cents = 10^8 USD_MICROCENTS - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B TOKENS are integer token counts. CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations).
      - `amount` integer, required
    - `spent` object
      - `unit` 'USD_MICROCENTS' | 'TOKENS' | 'CREDITS' | 'RISK_POINTS', required: Standard units. USD_MICROCENTS preserves precision for per-call and batched accounting (int64). - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars - 1 USD = 100 cents = 10^8 USD_MICROCENTS - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B TOKENS are integer token counts. CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations).
      - `amount` integer, required
    - `debt` object
      - `unit` 'USD_MICROCENTS' | 'TOKENS' | 'CREDITS' | 'RISK_POINTS', required: Standard units. USD_MICROCENTS preserves precision for per-call and batched accounting (int64). - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars - 1 USD = 100 cents = 10^8 USD_MICROCENTS - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B TOKENS are integer token counts. CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations).
      - `amount` integer, required
    - `allocated` object
      - `unit` 'USD_MICROCENTS' | 'TOKENS' | 'CREDITS' | 'RISK_POINTS', required: Standard units. USD_MICROCENTS preserves precision for per-call and batched accounting (int64). - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars - 1 USD = 100 cents = 10^8 USD_MICROCENTS - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B TOKENS are integer token counts. CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations).
      - `amount` integer, required
    - `overdraft_limit` object
      - `unit` 'USD_MICROCENTS' | 'TOKENS' | 'CREDITS' | 'RISK_POINTS', required: Standard units. USD_MICROCENTS preserves precision for per-call and batched accounting (int64). - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars - 1 USD = 100 cents = 10^8 USD_MICROCENTS - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B TOKENS are integer token counts. CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations).
      - `amount` integer, required
    - `is_over_limit` boolean: True when debt > overdraft_limit due to concurrent commits creating cumulative debt beyond the limit, or when ALLOW_IF_AVAILABLE could not cover the full overage delta and capped the charge. When true, ALL new reservations against this scope are blocked with 409 OVERDRAFT_LIMIT_EXCEEDED until reconciled (debt repaid below overdraft_limit, or is_over_limit cleared by operator). Defaults to false when absent.
  - `reason_code` string: Stable machine-readable reason for why a /decide call or a reservation returned decision=DENY. Populated on DecisionResponse.reason_code and on ReservationCreateResponse.reason_code. The human-readable `message` field on the parent response (when present) may carry additional context. EXTENSIBILITY (NORMATIVE): DecisionReasonCode is intentionally an OPEN string (not a closed enum) so that: (a) future minor versions of this base spec can add new values without a breaking change, and (b) companion extension specs (e.g., cycles-protocol-extensions-v0.1.26.yaml) can define additional reason codes without modifying the base schema. Clients MUST gracefully handle unknown values — log them and map to generic DENY handling (i.e., "the request was denied; treat as a terminal failure even if we don't recognize the specific reason"). KNOWN VALUES (v0.1.25 base): - BUDGET_EXCEEDED — remaining amount insufficient on at least one derived scope (evaluated against the requested estimate.amount). - BUDGET_FROZEN — a derived scope has a budget in FROZEN status (operator-set, no mutations allowed). - BUDGET_CLOSED — a derived scope has a budget in CLOSED status (permanently closed). - BUDGET_NOT_FOUND — no budget exists at any derived scope in the requested unit. On non-dry reserve and /v1/events paths this same condition surfaces as HTTP 404 with error=NOT_FOUND instead. - OVERDRAFT_LIMIT_EXCEEDED — either (a) debt + delta > overdraft_limit on commit, or (b) the scope is in over-limit state (is_over_limit=true) and no new reservations are permitted until reconciled. - DEBT_OUTSTANDING — a derived scope has debt > 0 and overdraft_limit == 0 (no policy permits further debt accrual). - TENANT_CLOSED — the owning tenant's status is CLOSED (deployments with a governance plane; added in revision 2026-07-10). Populated on /decide and dry_run=true evaluations; the persisting mutation surface reports the same condition as HTTP 409 error=TENANT_CLOSED instead (see the closed-tenant binding in ERROR SEMANTICS). KNOWN VALUES (v0.1.26 runtime extension): - ACTION_QUOTA_EXCEEDED — a per-kind or risk-class action quota rule was exceeded for the target scope and window. - ACTION_KIND_DENIED — the action kind is in the matching policy's denied_action_kinds list. - ACTION_KIND_NOT_ALLOWED — the matching policy has a non-empty allowed_action_kinds list and the action kind is not in it. See cycles-protocol-extensions-v0.1.26.yaml for full semantics, evaluation order, and DenyDetail structure (populated alongside reason_code for v0.1.26 denials). Distinct from the `ErrorCode` enum: reason codes appear only on 200 OK responses with decision=DENY; ErrorCodes appear only on 4xx/5xx responses with an `error` field. Some labels overlap (e.g. BUDGET_EXCEEDED appears in both sets) because the same underlying condition is reported two ways depending on the endpoint: /decide and dry_run surface it as a non-4xx DENY decision, while non-dry reserve surfaces it as a 409 error. SDK GENERATION GUIDANCE: Codegen tools SHOULD generate a string type (not a strict enum) and expose the known values above as constants or string literal union members for ergonomic use. SDK code MUST NOT reject unknown values at the deserialization boundary.
  - `retry_after_ms` integer
  - `cycles_evidence` object: Reference to the CyclesEvidence envelope emitted for an operation. The `evidence_id` is the sha256 content hash of the JCS-canonical envelope and is computed SYNCHRONOUSLY at decision time, so it is returned on the operation response even though the envelope is Ed25519-signed and stored ASYNCHRONOUSLY. A caller (e.g. an APS gateway) binds its own signed receipt to this evidence by recording `evidence_id`, then fetches the envelope at `cycles_evidence_url` (see `getEvidence`) to verify it offline. Because signing/storage is async, the envelope MAY be served shortly after this response — consumers SHOULD treat a transient `404` from `getEvidence` as not-yet-available and retry. TRANSPORT METADATA, NOT ATTESTED: `cycles_evidence` is added to the response for the caller's convenience and is NOT part of the evidence the envelope attests. `remaining_ttl_ms` on ReservationCreateResponse is likewise a volatile transport observation rather than attested decision data, so a same-key replay can safely recompute it. The `evidence_id` is computed over the operation response WITHOUT either transport-only field — the envelope's `payload.<artifact>.response` mirror (`cycles-evidence-v0.2.yaml`) keeps `additionalProperties: false` and omits them. Implementations MUST compute `evidence_id` before stamping transport-only fields onto the response, and MUST NOT include either field in the attested payload.
    - `evidence_id` string, required: sha256 content id of the emitted envelope; resolve via getEvidence.
    - `cycles_evidence_url` string, uri, required: Absolute URL of the signed envelope, formed as `{server_id}/evidence/{evidence_id}`. Note `server_id` is already the canonical deployment base INCLUDING the `/v1` prefix (e.g. `https://cycles.example.com/v1`), so the join adds only `/evidence/{evidence_id}` — it MUST NOT re-add `/v1`.

## Other responses

- `400`: Error response
- `401`: Error response
- `403`: Error response
- `404`: NOT_FOUND — no budget exists at any of the reservation's derived scopes in any unit. The message field carries the specific "Budget not found for provided scope: ..." detail. Distinct from 400 UNIT_MISMATCH (a budget exists at the scope but in a different unit).
- `409`: Error response
- `500`: Error response

---

[API](https://skmtc.dev/runcycles/apis/cycles-budget-authority-api.md) · [All operations](https://skmtc.dev/runcycles/apis/cycles-budget-authority-api/llms.txt) · [OpenAPI document](https://skmtc.dev/runcycles/apis/cycles-budget-authority-api/revisions/7d42267c1ea0?raw)
