--- title: "Querying usage and spend" description: "The workspace usage summary: aggregation windows, grouping, estimated vendor cost versus known-unpriced events, live budget posture read from gateway counters, and the completeness signal." --- Once [metering](/billing/metering) has landed usage events in Postgres, control exposes them as a read-only projection. One endpoint answers "what did this workspace consume, in this window, broken down this way" — and tells you how fresh and how complete that answer is. ## The endpoint ```http Usage summary GET /api/v1/workspaces/{workspace_id}/usage ?from=2026-09-01T00:00:00Z &to=2026-09-04T00:00:00Z &group_by=tool &limit=50 &cursor= ``` Implemented by `apps/control/app/services/usage_query.py` and served from `apps/control/app/api/observe.py`. See the [Observe API reference](/api-reference/observe). | Parameter | Notes | |---|---| | `from`, `to` | UTC timestamps. The window is **half-open**: `from <= occurred_at < to` | | `group_by` | One of `kind`, `key`, `model`, `provider`, `tool`. Default `kind` | | `limit` | 1–200, default 50. Applies to groups, not events | | `cursor` | Opaque group cursor from a prior `next_cursor` | Access requires workspace membership plus the `usage.read` action, which every role from **viewer** upward holds. A malformed window returns `422`; a database or schema-validation failure returns `503` with `Observe read model unavailable`, never a partial body. There is no raw per-event feed. The projection is aggregate-only. These attempt-level operational counts differ from the deduplicated logical calls shown in **Settings → Billing**. ## Aggregation windows Windows are caller-supplied. There are no fixed hourly or daily buckets, no server-side rollup tables, and no retention-driven downsampling — every request aggregates `usage_events` rows over exactly the range you name. To chart a series, issue one request per bucket. Grouping is a single dimension. `group_by=key` returns one row per virtual key; `group_by=tool` returns one row per namespaced tool name. Groups are ordered with `NULL` last (a model name is absent on a tool event, and a tool name is absent on a model event), and the `null` group is a legitimate result rather than an error. ## What comes back ```json Response shape { "schema_version": 1, "workspace_id": "ws_...", "window": { "from": "2026-09-01T00:00:00Z", "to": "2026-09-04T00:00:00Z" }, "group_by": "tool", "totals": { "...": 0 }, "groups": [{ "value": "stripe.create_refund", "totals": { "...": 0 } }], "budgets": [], "freshness_at": "2026-09-04T11:59:55Z", "completeness": "complete", "next_cursor": null } ``` The response is validated against `packages/proto/operator_usage_summary.schema.json` before it is returned. Totals and every group carry the same fields: | Field | Meaning | |---|---| | `event_count` | Usage events in the window | | `llm_requests` | Model attempt usage events; fallbacks can produce several for one logical request. Token counters apply to these events | | `tokens_in`, `tokens_out`, `tokens_cached`, `tokens_reasoning` | Summed token counts | | `tool_calls` | Summed governed tool calls | | `usd_micros` | Known estimated vendor cost subtotal, in integer micro-USD | | `unpriced_count` | Events excluded from that subtotal because `pricing_status` was `unknown` | | `partial_count` | Events where the client disconnected before the upstream finished | | `failure_count` | Events with an HTTP status below 200 or at 400 and above | `usd_micros` is an **estimate of your vendor cost**, computed from the rates in your own workspace pricebook. It is not Obol-invoiced spend, and a workspace with an empty pricebook reports a zero subtotal with a non-zero `unpriced_count`. What Obol charges you is a separate ledger: enrolled design partners invoice on the grandfathered **$1 / $3 / $10 / $1** per 1,000 meters, distinct from the public **$0.30 / $1.00 / $3.00 / $0.10** ladder — see [Stripe Billing](/billing/stripe). Integer aggregates are preserved exactly across the JSON boundary: PostgreSQL returns `SUM(bigint)` as a decimal, and the projection converts it with `int()` rather than a float or a narrowing cast. ### Freshness and completeness `freshness_at` is the maximum `ingested_at` in the window — how recently the drain wrote anything relevant. A `null` means nothing in that window has been ingested. `completeness` is either `complete` or `gap_detected`. `gap_detected` means the workspace carries a sticky `usage_ingestion_gap_at` marker: the bounded usage transport lost entries, so these totals are a floor, not a total. That marker also pauses Stripe meter delivery for the workspace, and nothing clears it automatically ([ADR-0045](/billing/metering)). ## Budget posture `budgets` is a separate read, and deliberately not a SQL aggregate. Budgets are enforced by the gateway against Redis counters, so the honest way to report remaining budget is to read the same counters the gateway reads. For each active, unrevoked, unexpired virtual key in the workspace, the projection reads `spend:{key_id}:{yyyyMM}` for the current calendar month and reports: | Field | Notes | |---|---| | `key_id`, `agent_id` | Which key this posture describes | | `window` | The current UTC calendar month, half-open | | `source` | Always `gateway_counter` | | `state` | `within`, `exhausted`, `not_set`, or `unavailable` | | `limit_usd_micros` | The key's configured monthly budget, or `null` | | `consumed_usd_micros` | The live counter value, or `null` | | `remaining_usd_micros` | `limit − consumed`, floored at zero | | `consumed_basis_points` | Consumption in basis points, capped at 10000 | Any Redis failure — unreachable, or a counter value that is not a plain decimal integer — projects `state: "unavailable"` with null figures for every key. It never projects zero. An unreadable budget must not look like an unused one. Because budget posture comes from Redis and usage totals come from Postgres, the two can disagree briefly. That is the intended shape of [ADR-0006](/concepts/architecture): spend counters are authoritative for enforcement in the moment, and the SQL projection is the durable record that catches up. ## The pricebook The rates behind `usd_micros` are per-workspace and versioned. `apps/control/app/api/pricing.py` exposes them: ```http Pricebook GET /api/v1/workspaces/{workspace_id}/pricing PUT /api/v1/workspaces/{workspace_id}/pricing ``` Reading requires `usage.read`. Replacing requires `pricing.write` (admin and owner), passes an `expected_revision` for optimistic concurrency, writes an audit row, and publishes a new workspace snapshot so the gateway prices subsequent calls with the new rates. Rates are integer micro-USD and must be non-negative; an explicit zero is a supported estimate, while an absent rate stays `unknown`. ## Related surfaces Per-tool-call records, queried separately from usage aggregates. Who changed the workspace, including who replaced the pricebook.