---
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.