--- title: "Virtual keys" description: "Mint, list, and revoke Obol virtual keys. Plaintext is returned once, on mint, and never re-served." --- A virtual key (`ob_live_…` or `ob_test_…`) is the credential an agent presents to the gateway. It is never a vendor credential and never an operator session token. See [Virtual keys](/security/virtual-keys) for what the key carries and how the gateway reads it. Plaintext is returned **once**, in the mint response, and is never stored, echoed, logged, or re-served. Control stores only `sha256(plaintext)` plus the last four characters. A lost key is re-minted, never recovered. Minting and revoking require the `key.manage` action (`developer`, `admin`, or `owner`). Listing requires `workspace.read`, which every role holds. A frozen workspace answers `409` on mint and revoke. ## Key object Returned by all three routes. The mint response adds `plaintext`. Key id, prefixed `key_`. This is the id, never the secret. `ob_live` or `ob_test`. The minted secret begins with this plus an underscore. `active`, `revoked`, or `expired`. Computed at read time: a key past `expires_at` reports `expired` whatever the stored column says, and a revoked key reports `revoked` regardless of expiry. The agent this key was minted for. The last four characters of the plaintext. The only fragment of the secret control retains. Model allowlist. `["*"]` when unrestricted. Tool allowlist. `["*"]` when unrestricted. `full` or `progressive`. Monthly spend cap in micro-USD, or `null` for no cap. Requests-per-minute limit, or `null`. Tokens-per-minute limit, or `null`. Surfaces this key may use. Defaults to `["mcp", "llm"]`. RFC 3339 UTC expiry, or `null`. Free-form attributes carried into the key context the gateway evaluates. Defaults to `{}`. RFC 3339 UTC. RFC 3339 UTC when the key was revoked, otherwise `null`. ## Mint a key `POST /api/v1/workspaces/{workspace_id}/keys` Requires `key.manage`. Returns `201` with the key object **plus** `plaintext`. The mint and its audit row commit in the same transaction, so a key that exists always has a row naming who created it. No secret material reaches the audit detail — it carries `agent_id`, `prefix`, `last4`, `tools_mode`, and `scopes`. ### Path parameters Workspace id, prefixed `ws_`. ### Body 1–128 characters. Names the agent this key belongs to. `ob_live` or `ob_test`. Defaults to `ob_live` in a `prod` workspace and `ob_test` otherwise. A `ob_test` prefix is rejected for a `prod` workspace. Model allowlist. Omitted or `null` stores `["*"]`. Tool allowlist. Omitted or `null` stores `["*"]`. `full` or `progressive`. Monthly spend cap in micro-USD. Requests per minute. Must be at least 0. Tokens per minute. Must be at least 0. Omitted or `null` stores `["mcp", "llm"]`. Free-form attributes. Omitted or `null` stores `{}`. RFC 3339 timestamp. After it passes, the key reports `expired`. ```bash cURL curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/keys" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "agent_id": "support-bot", "allowed_tools": ["stripe.create_refund"], "budget_micros_usd_month": 50000000, "rpm": 60 }' ``` ```json Response { "key_id": "key_01hxyz", "prefix": "ob_live", "status": "active", "agent_id": "support-bot", "last4": "a9Kd", "allowed_models": ["*"], "allowed_tools": ["stripe.create_refund"], "tools_mode": "full", "budget_micros_usd_month": 50000000, "rpm": 60, "tpm": null, "scopes": ["mcp", "llm"], "expires_at": null, "attributes": {}, "created_at": "2026-09-04T12:00:00Z", "revoked_at": null, "plaintext": "ob_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxa9Kd" } ``` The full secret. Present only in this response. Store it now. ### Errors | Status | Condition | |---|---| | `400` | `prefix` is not `ob_live` or `ob_test`; a test prefix was requested for a `prod` workspace; `tools_mode` is not `full` or `progressive`. | | `401` | No session, or the session did not verify. | | `403` | Role does not grant `key.manage`. | | `404` | No such workspace, or the operator is not a member. | | `409` | Workspace is frozen. | | `422` | Body validation failed — for example a missing `agent_id`, or a negative `rpm`. | After the commit, control writes the key context to the Redis hot path. If that write fails the response still succeeds: Postgres is the source of truth, and the gateway falls back to reading the key from control. ## List keys `GET /api/v1/workspaces/{workspace_id}/keys` Requires `workspace.read`. Returns every key in the workspace, revoked and expired ones included. ### Path parameters Workspace id, prefixed `ws_`. ```bash cURL curl "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/keys" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` ```json Response { "keys": [ { "key_id": "key_01hxyz", "prefix": "ob_live", "status": "active", "agent_id": "support-bot", "last4": "a9Kd", "allowed_models": ["*"], "allowed_tools": ["*"], "tools_mode": "full", "budget_micros_usd_month": null, "rpm": null, "tpm": null, "scopes": ["mcp", "llm"], "expires_at": null, "attributes": {}, "created_at": "2026-09-04T12:00:00Z", "revoked_at": null } ] } ``` Key objects, without `plaintext`. This route is not paginated. ## Revoke a key `POST /api/v1/workspaces/{workspace_id}/keys/{key_id}/revoke` Requires `key.manage`. Returns `200` with the updated key object. Revocation is idempotent: revoking an already-revoked key leaves `revoked_at` unchanged and still answers `200`. The key is resolved inside the tenant boundary — `workspace_id` is in the query predicate, not in a follow-up check — so another tenant's row is never loaded. ### Path parameters Workspace id, prefixed `ws_`. Key id, prefixed `key_`. ```bash cURL curl -X POST "$OBOL_CONTROL_URL/api/v1/workspaces/ws_acme_prod/keys/key_01hxyz/revoke" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` ```json Response { "key_id": "key_01hxyz", "prefix": "ob_live", "status": "revoked", "agent_id": "support-bot", "last4": "a9Kd", "allowed_models": ["*"], "allowed_tools": ["*"], "tools_mode": "full", "budget_micros_usd_month": null, "rpm": null, "tpm": null, "scopes": ["mcp", "llm"], "expires_at": null, "attributes": {}, "created_at": "2026-09-04T12:00:00Z", "revoked_at": "2026-09-04T12:30:00Z" } ``` ### Errors | Status | Condition | |---|---| | `401` | No session, or the session did not verify. | | `403` | Role does not grant `key.manage`. | | `404` | No such workspace or membership; or `{"detail": "key not found"}` for a key id that is not in this workspace. | | `409` | Workspace is frozen. | After the commit, control drops the key from the Redis hot path. A failed drop does not fail the request; the durable revocation stands and the gateway's fallback read reflects it.