# API keys

Personal API keys let a script act as you outside the browser: a whk_ bearer that carries a subset of your own permissions, changes with your permissions, and dies with your membership. Mint one, list yours, revoke it, and verify what a key can do.

Personal API keys act with their owner's current access, optionally narrowed
to selected permissions. Organization API keys use their own permission
policies and keep working independently of their creator.

Both use bearer authentication. The verification operation returns the key's
current permissions and resource scopes. Key management requires a browser
session; keys cannot issue or revoke other keys. Secrets are returned only
at creation.

See [API keys](/docs/web-app/api-keys) for choosing a key type, verification
and replacement, [Personal API keys](/docs/web-app/personal-api-keys) for
membership changes, and [Organization API keys](/docs/web-app/organization-api-keys)
for independent access policies.

## Preview an MCP client's authorization request

`POST /api/v1/oauth_authorizations/preview`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Self-service: acts on the caller's own account, no permission needed.

What the consent page shows for the request an MCP client sent the browser to: the client, the permissions it asked for that the person could grant, the person's full vocabulary, and whether keys may be scoped or are allowed at all. The whole request is validated first, so a redirect URI the client did not register is refused before anything is shown.

### Request

Request body:

- `client_id` · uuid · required
- `redirect_uri` · uri · required
- `state` · string
- `code_challenge` · string · required
- `code_challenge_method` · enum · required One of `S256`.
- `scope` · string: Space-separated permission keys the client asked for.
- `resource` · uri
- `response_type` · enum One of `code`.

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | OAuthAuthorizationPreview | The consent preview |
| 400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `client` · object · required
  - `id` · uuid · required
  - `name` · string · required
  - `client_uri` · string
  - `redirect_uri` · uri · required
- `requested_permissions` · array<string> · required
- `bearable_permissions` · array<APIKeyPermission> · required
  - `key` · string · required
  - `area` · string · required
  - `description` · string · required
  - `dangerous` · boolean · required: Minting a key that carries it needs a fresh, strong session.
- `scoping_enabled` · boolean · required
- `personal_api_keys_allowed` · boolean · required

### Example

```bash
curl -X POST "$WITHHUMAN_URL/api/v1/oauth_authorizations/preview" \
  -b "withhuman_session=$WITHHUMAN_SESSION" \
  -H "Content-Type: application/json" \
  -d '{
  "client_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "redirect_uri": "https://example.com/webhooks/withhuman",
  "state": "string",
  "code_challenge": "string",
  "code_challenge_method": "S256",
  "scope": "string",
  "resource": "https://example.com/webhooks/withhuman",
  "response_type": "code"
}'
```

200 response

```json
{
  "client": {
    "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
    "name": "string",
    "client_uri": "string",
    "redirect_uri": "https://example.com/webhooks/withhuman"
  },
  "requested_permissions": [
    "string"
  ],
  "bearable_permissions": [
    {
      "key": "pipeline.write",
      "area": "pipeline",
      "description": "string",
      "dangerous": true
    }
  ],
  "scoping_enabled": true,
  "personal_api_keys_allowed": true
}
```

## Approve or decline an MCP client

`POST /api/v1/oauth_authorizations`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `api_key.issue`

The person's decision on the consent page. Approval mints a personal API key named after the client, narrowed to the chosen permissions where scoping is available, under the same freshness and MFA rules as a hand-made key, and answers the client's redirect URI carrying a single-use code. Declining answers the redirect URI carrying access_denied. The browser navigates to the answer.

### Request

Request body:

- `client_id` · uuid · required
- `redirect_uri` · uri · required
- `state` · string
- `code_challenge` · string · required
- `code_challenge_method` · enum · required One of `S256`.
- `scope` · string: Space-separated permission keys the client asked for.
- `resource` · uri
- `response_type` · enum One of `code`.
- `approve` · boolean · required
- `permissions` · array<string>: The permissions to place on the key; omit to inherit everything the person holds.
- `expires_at` · date-time

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | object | Where the browser goes next |
| 400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `redirect_url` · uri · required

### Example

```bash
curl -X POST "$WITHHUMAN_URL/api/v1/oauth_authorizations" \
  -b "withhuman_session=$WITHHUMAN_SESSION" \
  -H "Content-Type: application/json" \
  -d '{
  "client_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "redirect_uri": "https://example.com/webhooks/withhuman",
  "state": "string",
  "code_challenge": "string",
  "code_challenge_method": "S256",
  "scope": "string",
  "resource": "https://example.com/webhooks/withhuman",
  "response_type": "code",
  "approve": true,
  "permissions": [
    "string"
  ],
  "expires_at": "2026-09-08T12:02:11Z"
}'
```

200 response

```json
{
  "redirect_url": "https://example.com/webhooks/withhuman"
}
```

## List your API keys

`GET /api/v1/me/api_keys`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Self-service: acts on the caller's own account, no permission needed.

Every personal API key you minted, newest first, with its status, and what the create form needs: whether the organization allows personal keys, whether this deployment and plan let a key carry a permission list, and the permissions you could place on one. Never the secret.

### Request

No parameters or body.

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | MyAPIKeysEnvelope | Your keys |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `keys` · array<APIKey> · required
  - `grants` · array<Grant> · required: Current organization-key grants projected from RBAC permission policies. Empty for personal keys.
    - `permission` · string · required: A registry key such as request.decide
    - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
    - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
  - `effective_grants` · array<Grant>: On detail responses, currently allowed organization-key grants.
    - `permission` · string · required: A registry key such as request.decide
    - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
    - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
  - `usable` · boolean · required: Whether the key is active and not blocked by organization policy or status.
  - `blocked_reason` · enum One of `organization_inactive`, `api_keys_disabled`.
  - `id` · uuid · required
  - `prefix` · string · required
  - `kind` · enum · required: Personal keys act as a member; organization keys act as themselves. One of `personal`, `organization`.
  - `membership_id` · uuid: The member a personal key acts as.
  - `created_by` · object: Who minted it. Present on the organization-wide list.
    - `membership_id` · uuid · required
    - `display_name` · string · required
    - `email` · string · required
  - `name` · string · required
  - `permissions` · array<string> · required: The narrowing list, or `null` when the key inherits everything its member holds. The key's effective permissions are always this list intersected with the member's current grants.
  - `assurance_at_issue` · string · required: The assurance of the session that minted the key (`strong`, `single_factor`), presented as the key's own when it decides.
  - `oauth_client` · object: The MCP client the key was minted for through the consent page. Absent for a key made by hand.
    - `id` · uuid · required
    - `name` · string · required
  - `status` · enum · required One of `active`, `expired`, `revoked`.
  - `created_at` · date-time · required
  - `expires_at` · date-time · required: `null` when the key lives until revoked.
  - `last_used_at` · date-time · required
  - `revoked_at` · date-time · required
  - `revocation_reason` · enum One of `manual`, `membership_deprovisioned`.
- `personal_api_keys_allowed` · boolean · required: The organization's switch.
- `scoping_enabled` · boolean · required: Whether a key may carry a permission list here. False in the open edition, where keys inherit the member's role.
- `bearable_permissions` · array<APIKeyPermission> · required
  - `key` · string · required
  - `area` · string · required
  - `description` · string · required
  - `dangerous` · boolean · required: Minting a key that carries it needs a fresh, strong session.

### Example

```bash
curl -X GET "$WITHHUMAN_URL/api/v1/me/api_keys" \
  -b "withhuman_session=$WITHHUMAN_SESSION"
```

200 response

```json
{
  "keys": [
    {
      "grants": [
        {
          "permission": "request.decide",
          "scope_kind": "organization",
          "scope_id": "string"
        }
      ],
      "effective_grants": [
        {
          "permission": "request.decide",
          "scope_kind": "organization",
          "scope_id": "string"
        }
      ],
      "usable": true,
      "blocked_reason": "organization_inactive",
      "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "prefix": "whk_4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31",
      "kind": "personal",
      "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "created_by": {
        "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
        "display_name": "string",
        "email": "string"
      },
      "name": "string",
      "permissions": [
        "string"
      ],
      "assurance_at_issue": "string",
      "oauth_client": {
        "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
        "name": "string"
      },
      "status": "active",
      "created_at": "2026-09-08T12:02:11Z",
      "expires_at": "2026-09-08T12:02:11Z",
      "last_used_at": "2026-09-08T12:02:11Z",
      "revoked_at": "2026-09-08T12:02:11Z",
      "revocation_reason": "manual"
    }
  ],
  "personal_api_keys_allowed": true,
  "scoping_enabled": true,
  "bearable_permissions": [
    {
      "key": "pipeline.write",
      "area": "pipeline",
      "description": "string",
      "dangerous": true
    }
  ]
}
```

## Create an API key

`POST /api/v1/me/api_keys`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `api_key.issue`

Mints a personal API key bound to your membership. The raw token is returned exactly once; withHuman stores only its hash. Without `permissions` the key inherits everything you hold, live, so a role change reaches the key on its next request. With `permissions` (hosted edition) the key is narrowed to that list; every entry must be a permission you hold, and the list can only remove, never add. A key that could decide requests or use a dangerous permission is minted only from a session that passes the organization's re-authentication and MFA rules right now, and the key keeps that session's assurance for later decisions. 403 api_keys_disabled when the organization has turned personal keys off, 403 api_key_scoping_unavailable when this edition cannot scope keys, 403 plan_required when the plan cannot.

### Request

Request body:

- `name` · string · required
- `permissions` · array<string>: Narrow the key to these permissions. Omit to inherit everything you hold. Each must be a permission you hold; only in the hosted edition.
- `expires_at` · date-time: When the key stops working. Omit for a key that lives until revoked, unless the deployment caps key lifetime.

### Response

| Status | Body | Description |
| --- | --- | --- |
| 201 | IssuedAPIKeyEnvelope | The key and, once, its token |
| 400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (201):

- `key` · APIKey · required: An API key without its secret. Personal prefixes start with `whk_`; organization prefixes start with `who_`.
  - `grants` · array<Grant> · required: Current organization-key grants projected from RBAC permission policies. Empty for personal keys.
    - `permission` · string · required: A registry key such as request.decide
    - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
    - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
  - `effective_grants` · array<Grant>: On detail responses, currently allowed organization-key grants.
    - `permission` · string · required: A registry key such as request.decide
    - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
    - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
  - `usable` · boolean · required: Whether the key is active and not blocked by organization policy or status.
  - `blocked_reason` · enum One of `organization_inactive`, `api_keys_disabled`.
  - `id` · uuid · required
  - `prefix` · string · required
  - `kind` · enum · required: Personal keys act as a member; organization keys act as themselves. One of `personal`, `organization`.
  - `membership_id` · uuid: The member a personal key acts as.
  - `created_by` · object: Who minted it. Present on the organization-wide list.
    - `membership_id` · uuid · required
    - `display_name` · string · required
    - `email` · string · required
  - `name` · string · required
  - `permissions` · array<string> · required: The narrowing list, or `null` when the key inherits everything its member holds. The key's effective permissions are always this list intersected with the member's current grants.
  - `assurance_at_issue` · string · required: The assurance of the session that minted the key (`strong`, `single_factor`), presented as the key's own when it decides.
  - `oauth_client` · object: The MCP client the key was minted for through the consent page. Absent for a key made by hand.
    - `id` · uuid · required
    - `name` · string · required
  - `status` · enum · required One of `active`, `expired`, `revoked`.
  - `created_at` · date-time · required
  - `expires_at` · date-time · required: `null` when the key lives until revoked.
  - `last_used_at` · date-time · required
  - `revoked_at` · date-time · required
  - `revocation_reason` · enum One of `manual`, `membership_deprovisioned`.
- `token` · string · required: The raw key, shown once: `whk_<id>_<secret>`.

### Example

```bash
curl -X POST "$WITHHUMAN_URL/api/v1/me/api_keys" \
  -b "withhuman_session=$WITHHUMAN_SESSION" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "terraform",
  "permissions": [
    "pipeline.read",
    "pipeline.write",
    "pipeline.activate"
  ],
  "expires_at": "2026-09-08T12:02:11Z"
}'
```

201 response

```json
{
  "key": {
    "grants": [
      {
        "permission": "request.decide",
        "scope_kind": "organization",
        "scope_id": "string"
      }
    ],
    "effective_grants": [
      {
        "permission": "request.decide",
        "scope_kind": "organization",
        "scope_id": "string"
      }
    ],
    "usable": true,
    "blocked_reason": "organization_inactive",
    "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
    "prefix": "whk_4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31",
    "kind": "personal",
    "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
    "created_by": {
      "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "display_name": "string",
      "email": "string"
    },
    "name": "string",
    "permissions": [
      "string"
    ],
    "assurance_at_issue": "string",
    "oauth_client": {
      "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "name": "string"
    },
    "status": "active",
    "created_at": "2026-09-08T12:02:11Z",
    "expires_at": "2026-09-08T12:02:11Z",
    "last_used_at": "2026-09-08T12:02:11Z",
    "revoked_at": "2026-09-08T12:02:11Z",
    "revocation_reason": "manual"
  },
  "token": "whk_4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31_x5nH9v…"
}
```

## Verify an API key

`GET /api/v1/api_keys/verify`

Auth: Personal API key (`Authorization: Bearer $WITHHUMAN_API_KEY`)

Self-service: acts on the caller's own account, no permission needed.

The first call a script makes with a fresh key. Answers the key's own row and the permissions it holds right now, after narrowing and your current roles. Only a request authenticated with a key can verify one; a session answers 400.

### Request

No parameters or body.

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | VerifiedAPIKeyEnvelope | The key behind this request |
| 400 | ErrorResponse | The request did not authenticate with an API key |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `effective_grants` · array<Grant>
  - `permission` · string · required: A registry key such as request.decide
  - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
  - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
- `key` · APIKey · required: An API key without its secret. Personal prefixes start with `whk_`; organization prefixes start with `who_`.
  - `grants` · array<Grant> · required: Current organization-key grants projected from RBAC permission policies. Empty for personal keys.
    - `permission` · string · required: A registry key such as request.decide
    - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
    - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
  - `effective_grants` · array<Grant>: On detail responses, currently allowed organization-key grants.
    - `permission` · string · required: A registry key such as request.decide
    - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
    - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
  - `usable` · boolean · required: Whether the key is active and not blocked by organization policy or status.
  - `blocked_reason` · enum One of `organization_inactive`, `api_keys_disabled`.
  - `id` · uuid · required
  - `prefix` · string · required
  - `kind` · enum · required: Personal keys act as a member; organization keys act as themselves. One of `personal`, `organization`.
  - `membership_id` · uuid: The member a personal key acts as.
  - `created_by` · object: Who minted it. Present on the organization-wide list.
    - `membership_id` · uuid · required
    - `display_name` · string · required
    - `email` · string · required
  - `name` · string · required
  - `permissions` · array<string> · required: The narrowing list, or `null` when the key inherits everything its member holds. The key's effective permissions are always this list intersected with the member's current grants.
  - `assurance_at_issue` · string · required: The assurance of the session that minted the key (`strong`, `single_factor`), presented as the key's own when it decides.
  - `oauth_client` · object: The MCP client the key was minted for through the consent page. Absent for a key made by hand.
    - `id` · uuid · required
    - `name` · string · required
  - `status` · enum · required One of `active`, `expired`, `revoked`.
  - `created_at` · date-time · required
  - `expires_at` · date-time · required: `null` when the key lives until revoked.
  - `last_used_at` · date-time · required
  - `revoked_at` · date-time · required
  - `revocation_reason` · enum One of `manual`, `membership_deprovisioned`.
- `effective_permissions` · array<string> · required: What the key may do right now, after narrowing and the member's current roles.

### Example

```bash
curl -X GET "$WITHHUMAN_URL/api/v1/api_keys/verify" \
  -H "Authorization: Bearer $WITHHUMAN_API_KEY"
```

200 response

```json
{
  "effective_grants": [
    {
      "permission": "request.decide",
      "scope_kind": "organization",
      "scope_id": "string"
    }
  ],
  "key": {
    "grants": [
      {
        "permission": "request.decide",
        "scope_kind": "organization",
        "scope_id": "string"
      }
    ],
    "effective_grants": [
      {
        "permission": "request.decide",
        "scope_kind": "organization",
        "scope_id": "string"
      }
    ],
    "usable": true,
    "blocked_reason": "organization_inactive",
    "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
    "prefix": "whk_4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31",
    "kind": "personal",
    "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
    "created_by": {
      "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "display_name": "string",
      "email": "string"
    },
    "name": "string",
    "permissions": [
      "string"
    ],
    "assurance_at_issue": "string",
    "oauth_client": {
      "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "name": "string"
    },
    "status": "active",
    "created_at": "2026-09-08T12:02:11Z",
    "expires_at": "2026-09-08T12:02:11Z",
    "last_used_at": "2026-09-08T12:02:11Z",
    "revoked_at": "2026-09-08T12:02:11Z",
    "revocation_reason": "manual"
  },
  "effective_permissions": [
    "string"
  ]
}
```

## Revoke one of your API keys

`DELETE /api/v1/me/api_keys/{id}`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Self-service: acts on the caller's own account, no permission needed.

Revokes a key you minted. It stops working immediately. Revoking an already revoked key answers 204 again.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes | The API key's id, the part after `whk_` or `who_` in the token. |

### Response

| Status | Body | Description |
| --- | --- | --- |
| 204 |  | The key is revoked |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | No such key of yours |

### Example

```bash
curl -X DELETE "$WITHHUMAN_URL/api/v1/me/api_keys/{id}" \
  -b "withhuman_session=$WITHHUMAN_SESSION"
```

## List every API key in the organization

`GET /api/v1/api_keys`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `api_key.read`

The oversight list for owners, admins, and auditors. Both personal and organization keys, newest first, with creator provenance. Never the secret.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `kind` | query | string | no | Omit to return both key kinds. |

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | APIKeysEnvelope | The organization's keys |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `keys` · array<APIKey> · required
  - `grants` · array<Grant> · required: Current organization-key grants projected from RBAC permission policies. Empty for personal keys.
    - `permission` · string · required: A registry key such as request.decide
    - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
    - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
  - `effective_grants` · array<Grant>: On detail responses, currently allowed organization-key grants.
    - `permission` · string · required: A registry key such as request.decide
    - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
    - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
  - `usable` · boolean · required: Whether the key is active and not blocked by organization policy or status.
  - `blocked_reason` · enum One of `organization_inactive`, `api_keys_disabled`.
  - `id` · uuid · required
  - `prefix` · string · required
  - `kind` · enum · required: Personal keys act as a member; organization keys act as themselves. One of `personal`, `organization`.
  - `membership_id` · uuid: The member a personal key acts as.
  - `created_by` · object: Who minted it. Present on the organization-wide list.
    - `membership_id` · uuid · required
    - `display_name` · string · required
    - `email` · string · required
  - `name` · string · required
  - `permissions` · array<string> · required: The narrowing list, or `null` when the key inherits everything its member holds. The key's effective permissions are always this list intersected with the member's current grants.
  - `assurance_at_issue` · string · required: The assurance of the session that minted the key (`strong`, `single_factor`), presented as the key's own when it decides.
  - `oauth_client` · object: The MCP client the key was minted for through the consent page. Absent for a key made by hand.
    - `id` · uuid · required
    - `name` · string · required
  - `status` · enum · required One of `active`, `expired`, `revoked`.
  - `created_at` · date-time · required
  - `expires_at` · date-time · required: `null` when the key lives until revoked.
  - `last_used_at` · date-time · required
  - `revoked_at` · date-time · required
  - `revocation_reason` · enum One of `manual`, `membership_deprovisioned`.

### Example

```bash
curl -X GET "$WITHHUMAN_URL/api/v1/api_keys" \
  -b "withhuman_session=$WITHHUMAN_SESSION"
```

200 response

```json
{
  "keys": [
    {
      "grants": [
        {
          "permission": "request.decide",
          "scope_kind": "organization",
          "scope_id": "string"
        }
      ],
      "effective_grants": [
        {
          "permission": "request.decide",
          "scope_kind": "organization",
          "scope_id": "string"
        }
      ],
      "usable": true,
      "blocked_reason": "organization_inactive",
      "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "prefix": "whk_4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31",
      "kind": "personal",
      "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "created_by": {
        "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
        "display_name": "string",
        "email": "string"
      },
      "name": "string",
      "permissions": [
        "string"
      ],
      "assurance_at_issue": "string",
      "oauth_client": {
        "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
        "name": "string"
      },
      "status": "active",
      "created_at": "2026-09-08T12:02:11Z",
      "expires_at": "2026-09-08T12:02:11Z",
      "last_used_at": "2026-09-08T12:02:11Z",
      "revoked_at": "2026-09-08T12:02:11Z",
      "revocation_reason": "manual"
    }
  ]
}
```

## Inspect an API key

`GET /api/v1/api_keys/{id}`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `api_key.read`

Returns metadata, configured grants, effective organization-key grants, and policy availability. Never returns a secret.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes | The API key's id, the part after `whk_` or `who_` in the token. |

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | APIKey | Key metadata |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `grants` · array<Grant> · required: Current organization-key grants projected from RBAC permission policies. Empty for personal keys.
  - `permission` · string · required: A registry key such as request.decide
  - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
  - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
- `effective_grants` · array<Grant>: On detail responses, currently allowed organization-key grants.
  - `permission` · string · required: A registry key such as request.decide
  - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
  - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
- `usable` · boolean · required: Whether the key is active and not blocked by organization policy or status.
- `blocked_reason` · enum One of `organization_inactive`, `api_keys_disabled`.
- `id` · uuid · required
- `prefix` · string · required
- `kind` · enum · required: Personal keys act as a member; organization keys act as themselves. One of `personal`, `organization`.
- `membership_id` · uuid: The member a personal key acts as.
- `created_by` · object: Who minted it. Present on the organization-wide list.
  - `membership_id` · uuid · required
  - `display_name` · string · required
  - `email` · string · required
- `name` · string · required
- `permissions` · array<string> · required: The narrowing list, or `null` when the key inherits everything its member holds. The key's effective permissions are always this list intersected with the member's current grants.
- `assurance_at_issue` · string · required: The assurance of the session that minted the key (`strong`, `single_factor`), presented as the key's own when it decides.
- `oauth_client` · object: The MCP client the key was minted for through the consent page. Absent for a key made by hand.
  - `id` · uuid · required
  - `name` · string · required
- `status` · enum · required One of `active`, `expired`, `revoked`.
- `created_at` · date-time · required
- `expires_at` · date-time · required: `null` when the key lives until revoked.
- `last_used_at` · date-time · required
- `revoked_at` · date-time · required
- `revocation_reason` · enum One of `manual`, `membership_deprovisioned`.

### Example

```bash
curl -X GET "$WITHHUMAN_URL/api/v1/api_keys/{id}" \
  -b "withhuman_session=$WITHHUMAN_SESSION"
```

200 response

```json
{
  "grants": [
    {
      "permission": "request.decide",
      "scope_kind": "organization",
      "scope_id": "string"
    }
  ],
  "effective_grants": [
    {
      "permission": "request.decide",
      "scope_kind": "organization",
      "scope_id": "string"
    }
  ],
  "usable": true,
  "blocked_reason": "organization_inactive",
  "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "prefix": "whk_4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31",
  "kind": "personal",
  "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "created_by": {
    "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
    "display_name": "string",
    "email": "string"
  },
  "name": "string",
  "permissions": [
    "string"
  ],
  "assurance_at_issue": "string",
  "oauth_client": {
    "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
    "name": "string"
  },
  "status": "active",
  "created_at": "2026-09-08T12:02:11Z",
  "expires_at": "2026-09-08T12:02:11Z",
  "last_used_at": "2026-09-08T12:02:11Z",
  "revoked_at": "2026-09-08T12:02:11Z",
  "revocation_reason": "manual"
}
```

## Revoke any API key

`DELETE /api/v1/api_keys/{id}`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `api_key.revoke`

Revokes anyone's key. It stops working immediately and the audit log records who revoked it.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes | The API key's id, the part after `whk_` or `who_` in the token. |

### Response

| Status | Body | Description |
| --- | --- | --- |
| 204 |  | The key is revoked |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | No such key |

### Example

```bash
curl -X DELETE "$WITHHUMAN_URL/api/v1/api_keys/{id}" \
  -b "withhuman_session=$WITHHUMAN_SESSION"
```

## Create an organization API key

`POST /api/v1/api_keys`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `api_key.organization.issue`

Hosted edition only. The open edition answers 404.

Delegates explicit scoped grants to an organization-owned key. Requires a fresh human session. The first response shows the secret once; an idempotent retry returns metadata without the secret. Keys survive creator offboarding.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | string | yes | A key of your choosing that identifies this call, so a retry does not act twice. See Idempotency in the API overview. |

Request body:

- `name` · string · required
- `permission_policies` · array<PermissionPolicySpec> · required: Policies assigned to the key through RBAC. Every permission must allow organization keys and be held by the issuer at each selected resource or wider.
  - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
  - `resource_ids` · array<string> · required: Empty for organization scope; otherwise the selected team IDs or agent slugs.
  - `permissions` · array<string> · required
- `expires_at` · date-time

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | OrganizationAPIKeyCreated | Already created. secret_available is false; the secret cannot be recovered. |
| 201 | OrganizationAPIKeyCreated | Created, including the one-time secret. Cache-Control is no-store. |
| 400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 409 | ErrorResponse | Idempotency key reused for different intent |

Response body (200):

- `secret_available` · boolean · required
- `token` · string: Returned only in the first creation response.
- `grants` · array<Grant> · required: Current organization-key grants projected from RBAC permission policies. Empty for personal keys.
  - `permission` · string · required: A registry key such as request.decide
  - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
  - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
- `effective_grants` · array<Grant>: On detail responses, currently allowed organization-key grants.
  - `permission` · string · required: A registry key such as request.decide
  - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
  - `scope_id` · string: The team id or agent slug the grant applies to; absent at organization scope
- `usable` · boolean · required: Whether the key is active and not blocked by organization policy or status.
- `blocked_reason` · enum One of `organization_inactive`, `api_keys_disabled`.
- `id` · uuid · required
- `prefix` · string · required
- `kind` · enum · required: Personal keys act as a member; organization keys act as themselves. One of `personal`, `organization`.
- `membership_id` · uuid: The member a personal key acts as.
- `created_by` · object: Who minted it. Present on the organization-wide list.
  - `membership_id` · uuid · required
  - `display_name` · string · required
  - `email` · string · required
- `name` · string · required
- `permissions` · array<string> · required: The narrowing list, or `null` when the key inherits everything its member holds. The key's effective permissions are always this list intersected with the member's current grants.
- `assurance_at_issue` · string · required: The assurance of the session that minted the key (`strong`, `single_factor`), presented as the key's own when it decides.
- `status` · enum · required One of `active`, `expired`, `revoked`.
- `created_at` · date-time · required
- `expires_at` · date-time · required: `null` when the key lives until revoked.
- `last_used_at` · date-time · required
- `revoked_at` · date-time · required
- `revocation_reason` · enum One of `manual`, `membership_deprovisioned`.

### Example

```bash
curl -X POST "$WITHHUMAN_URL/api/v1/api_keys" \
  -b "withhuman_session=$WITHHUMAN_SESSION" \
  -H "Idempotency-Key: 4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "string",
  "permission_policies": [
    {
      "scope_kind": "organization",
      "resource_ids": [
        "string"
      ],
      "permissions": [
        "string"
      ]
    }
  ],
  "expires_at": "2026-09-08T12:02:11Z"
}'
```

200 response

```json
{
  "secret_available": true,
  "token": "string",
  "grants": [
    {
      "permission": "request.decide",
      "scope_kind": "organization",
      "scope_id": "string"
    }
  ],
  "effective_grants": [
    {
      "permission": "request.decide",
      "scope_kind": "organization",
      "scope_id": "string"
    }
  ],
  "usable": true,
  "blocked_reason": "organization_inactive",
  "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "prefix": "whk_4f1c9a2e-7b3d-4e8f-a1c5-2d6b8e0f9a31",
  "kind": "personal",
  "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "created_by": {
    "membership_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
    "display_name": "string",
    "email": "string"
  },
  "name": "string",
  "permissions": [
    "string"
  ],
  "assurance_at_issue": "string",
  "status": "active",
  "created_at": "2026-09-08T12:02:11Z",
  "expires_at": "2026-09-08T12:02:11Z",
  "last_used_at": "2026-09-08T12:02:11Z",
  "revoked_at": "2026-09-08T12:02:11Z",
  "revocation_reason": "manual"
}
```

## Read organization key creation choices

`GET /api/v1/api_keys/options`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `api_key.organization.issue`

Hosted edition only. The open edition answers 404.

Grantable permissions and scopes, policy availability, and the deployment lifetime cap. Does not require permission to read the key inventory.

### Request

No parameters or body.

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | OrganizationAPIKeyOptions | Available creation choices |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `organization_api_keys_allowed` · boolean · required
- `resources` · array<PermissionResource> · required
  - `id` · string · required
  - `name` · string · required
  - `kind` · enum · required One of `organization`, `team`, `agent`.
- `permissions` · array<PermissionDescriptor> · required
  - `key` · string · required: The permission key, shaped `resource.action`.
  - `area` · string · required: The key's first segment, for grouping.
  - `description` · string · required: What the permission allows.
  - `holders` · array<enum> · required: Who can hold it: people, agents, or the gateway.
  - `resource` · enum · required: What kind of thing a check on this permission is about. A grant must cover that thing's scope. One of `organization`, `team`, `agent`, `request`, `scope`.
  - `dangerous` · boolean · required: Whether granting or using it changes what other people can do, or cannot be undone. Such permissions need a fresh, strong sign-in to use and never arrive through directory sync or first SSO sign-in.
- `max_ttl` · string · required: Maximum lifetime as a duration. 0s means no cap.

### Example

```bash
curl -X GET "$WITHHUMAN_URL/api/v1/api_keys/options" \
  -b "withhuman_session=$WITHHUMAN_SESSION"
```

200 response

```json
{
  "organization_api_keys_allowed": true,
  "resources": [
    {
      "id": "string",
      "name": "string",
      "kind": "organization"
    }
  ],
  "permissions": [
    {
      "key": "request.decide",
      "area": "request",
      "description": "Approve or deny requests routed to you",
      "holders": [
        "human"
      ],
      "resource": "organization",
      "dangerous": true
    }
  ],
  "max_ttl": "string"
}
```

## Get organization key permission policies

`GET /api/v1/api_keys/{id}/permission_policies`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Self-service: acts on the caller's own account, no permission needed.

Hosted edition only. The open edition answers 404.

Manage permission policies assigned directly to an organization key. Requires a human session, policy authority at each affected scope, and api_key.organization.issue for mutations. Mutations require fresh authentication and the current MFA policy. Expired and revoked keys are read-only.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | PermissionPoliciesPage | Policy operation completed |
| 400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 409 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `name` · string · required
- `policies` · array<PermissionPolicy> · required
  - `scope_kind` · enum · required One of `organization`, `team`, `agent`.
  - `resource_ids` · array<string> · required: Empty for organization scope; otherwise the selected team IDs or agent slugs.
  - `permissions` · array<string> · required
  - `id` · uuid · required
  - `revision` · int64 · required
  - `resources` · array<PermissionResource> · required
    - `id` · string · required
    - `name` · string · required
    - `kind` · enum · required One of `organization`, `team`, `agent`.
  - `source` · enum · required One of `manual`, `directory`.
  - `via_team_id` · uuid
  - `via_team_name` · string
  - `can_edit` · boolean · required
  - `read_only_reason` · string
- `resources` · array<PermissionResource> · required
  - `id` · string · required
  - `name` · string · required
  - `kind` · enum · required One of `organization`, `team`, `agent`.
- `permissions` · array<PermissionDescriptor> · required
  - `key` · string · required: The permission key, shaped `resource.action`.
  - `area` · string · required: The key's first segment, for grouping.
  - `description` · string · required: What the permission allows.
  - `holders` · array<enum> · required: Who can hold it: people, agents, or the gateway.
  - `resource` · enum · required: What kind of thing a check on this permission is about. A grant must cover that thing's scope. One of `organization`, `team`, `agent`, `request`, `scope`.
  - `dangerous` · boolean · required: Whether granting or using it changes what other people can do, or cannot be undone. Such permissions need a fresh, strong sign-in to use and never arrive through directory sync or first SSO sign-in.
- `can_create` · boolean · required
- `is_owner` · boolean · required
- `read_only_reason` · string

### Example

```bash
curl -X GET "$WITHHUMAN_URL/api/v1/api_keys/{id}/permission_policies" \
  -b "withhuman_session=$WITHHUMAN_SESSION"
```

200 response

```json
{
  "name": "string",
  "policies": [
    {
      "scope_kind": "organization",
      "resource_ids": [
        "string"
      ],
      "permissions": [
        "string"
      ],
      "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "revision": 1,
      "resources": [
        {
          "id": "string",
          "name": "string",
          "kind": "organization"
        }
      ],
      "source": "manual",
      "via_team_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
      "via_team_name": "string",
      "can_edit": true,
      "read_only_reason": "string"
    }
  ],
  "resources": [
    {
      "id": "string",
      "name": "string",
      "kind": "organization"
    }
  ],
  "permissions": [
    {
      "key": "request.decide",
      "area": "request",
      "description": "Approve or deny requests routed to you",
      "holders": [
        "human"
      ],
      "resource": "organization",
      "dangerous": true
    }
  ],
  "can_create": true,
  "is_owner": true,
  "read_only_reason": "string"
}
```

## Post organization key permission policies

`POST /api/v1/api_keys/{id}/permission_policies`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `permission_policy.write`

Hosted edition only. The open edition answers 404.

Manage permission policies assigned directly to an organization key. Requires a human session, policy authority at each affected scope, and api_key.organization.issue for mutations. Mutations require fresh authentication and the current MFA policy. Expired and revoked keys are read-only.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |
| `Idempotency-Key` | header | string | yes |  |

Request body:

- `scope_kind` · enum · required One of `organization`, `team`, `agent`.
- `resource_ids` · array<string> · required: Empty for organization scope; otherwise the selected team IDs or agent slugs.
- `permissions` · array<string> · required

### Response

| Status | Body | Description |
| --- | --- | --- |
| 201 | PermissionPolicy | Policy operation completed |
| 400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 409 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (201):

- `scope_kind` · enum · required One of `organization`, `team`, `agent`.
- `resource_ids` · array<string> · required: Empty for organization scope; otherwise the selected team IDs or agent slugs.
- `permissions` · array<string> · required
- `id` · uuid · required
- `revision` · int64 · required
- `resources` · array<PermissionResource> · required
  - `id` · string · required
  - `name` · string · required
  - `kind` · enum · required One of `organization`, `team`, `agent`.
- `source` · enum · required One of `manual`, `directory`.
- `via_team_id` · uuid
- `via_team_name` · string
- `can_edit` · boolean · required
- `read_only_reason` · string

### Example

```bash
curl -X POST "$WITHHUMAN_URL/api/v1/api_keys/{id}/permission_policies" \
  -b "withhuman_session=$WITHHUMAN_SESSION" \
  -H "Idempotency-Key: string" \
  -H "Content-Type: application/json" \
  -d '{
  "scope_kind": "organization",
  "resource_ids": [
    "string"
  ],
  "permissions": [
    "string"
  ]
}'
```

201 response

```json
{
  "scope_kind": "organization",
  "resource_ids": [
    "string"
  ],
  "permissions": [
    "string"
  ],
  "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "revision": 1,
  "resources": [
    {
      "id": "string",
      "name": "string",
      "kind": "organization"
    }
  ],
  "source": "manual",
  "via_team_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "via_team_name": "string",
  "can_edit": true,
  "read_only_reason": "string"
}
```

## Get organization key permission policy

`GET /api/v1/api_keys/{id}/permission_policies/{policy_id}`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Self-service: acts on the caller's own account, no permission needed.

Hosted edition only. The open edition answers 404.

Manage permission policies assigned directly to an organization key. Requires a human session, policy authority at each affected scope, and api_key.organization.issue for mutations. Mutations require fresh authentication and the current MFA policy. Expired and revoked keys are read-only.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |
| `policy_id` | path | uuid | yes |  |

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | PermissionPolicy | Policy operation completed |
| 400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 409 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `scope_kind` · enum · required One of `organization`, `team`, `agent`.
- `resource_ids` · array<string> · required: Empty for organization scope; otherwise the selected team IDs or agent slugs.
- `permissions` · array<string> · required
- `id` · uuid · required
- `revision` · int64 · required
- `resources` · array<PermissionResource> · required
  - `id` · string · required
  - `name` · string · required
  - `kind` · enum · required One of `organization`, `team`, `agent`.
- `source` · enum · required One of `manual`, `directory`.
- `via_team_id` · uuid
- `via_team_name` · string
- `can_edit` · boolean · required
- `read_only_reason` · string

### Example

```bash
curl -X GET "$WITHHUMAN_URL/api/v1/api_keys/{id}/permission_policies/{policy_id}" \
  -b "withhuman_session=$WITHHUMAN_SESSION"
```

200 response

```json
{
  "scope_kind": "organization",
  "resource_ids": [
    "string"
  ],
  "permissions": [
    "string"
  ],
  "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "revision": 1,
  "resources": [
    {
      "id": "string",
      "name": "string",
      "kind": "organization"
    }
  ],
  "source": "manual",
  "via_team_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "via_team_name": "string",
  "can_edit": true,
  "read_only_reason": "string"
}
```

## Put organization key permission policy

`PUT /api/v1/api_keys/{id}/permission_policies/{policy_id}`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `permission_policy.write`

Hosted edition only. The open edition answers 404.

Manage permission policies assigned directly to an organization key. Requires a human session, policy authority at each affected scope, and api_key.organization.issue for mutations. Mutations require fresh authentication and the current MFA policy. Expired and revoked keys are read-only.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |
| `policy_id` | path | uuid | yes |  |
| `Idempotency-Key` | header | string | yes |  |

Request body:

- `scope_kind` · enum · required One of `organization`, `team`, `agent`.
- `resource_ids` · array<string> · required: Empty for organization scope; otherwise the selected team IDs or agent slugs.
- `permissions` · array<string> · required
- `revision` · int64 · required: Required when updating an existing policy.

### Response

| Status | Body | Description |
| --- | --- | --- |
| 200 | PermissionPolicy | Policy operation completed |
| 400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 409 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

Response body (200):

- `scope_kind` · enum · required One of `organization`, `team`, `agent`.
- `resource_ids` · array<string> · required: Empty for organization scope; otherwise the selected team IDs or agent slugs.
- `permissions` · array<string> · required
- `id` · uuid · required
- `revision` · int64 · required
- `resources` · array<PermissionResource> · required
  - `id` · string · required
  - `name` · string · required
  - `kind` · enum · required One of `organization`, `team`, `agent`.
- `source` · enum · required One of `manual`, `directory`.
- `via_team_id` · uuid
- `via_team_name` · string
- `can_edit` · boolean · required
- `read_only_reason` · string

### Example

```bash
curl -X PUT "$WITHHUMAN_URL/api/v1/api_keys/{id}/permission_policies/{policy_id}" \
  -b "withhuman_session=$WITHHUMAN_SESSION" \
  -H "Idempotency-Key: string" \
  -H "Content-Type: application/json" \
  -d '{
  "scope_kind": "organization",
  "resource_ids": [
    "string"
  ],
  "permissions": [
    "string"
  ],
  "revision": 1
}'
```

200 response

```json
{
  "scope_kind": "organization",
  "resource_ids": [
    "string"
  ],
  "permissions": [
    "string"
  ],
  "id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "revision": 1,
  "resources": [
    {
      "id": "string",
      "name": "string",
      "kind": "organization"
    }
  ],
  "source": "manual",
  "via_team_id": "7ab8c8ec-7b2d-4fd6-9b52-752f9515eb71",
  "via_team_name": "string",
  "can_edit": true,
  "read_only_reason": "string"
}
```

## Delete organization key permission policy

`DELETE /api/v1/api_keys/{id}/permission_policies/{policy_id}`

Auth: Reviewer session cookie (`withhuman_session`), browser only; a personal API key is refused

Requires: `permission_policy.write`

Hosted edition only. The open edition answers 404.

Manage permission policies assigned directly to an organization key. Requires a human session, policy authority at each affected scope, and api_key.organization.issue for mutations. Mutations require fresh authentication and the current MFA policy. Expired and revoked keys are read-only.

### Request

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | uuid | yes |  |
| `policy_id` | path | uuid | yes |  |
| `Idempotency-Key` | header | string | yes |  |
| `revision` | query | integer | yes |  |

### Response

| Status | Body | Description |
| --- | --- | --- |
| 204 |  | Policy operation completed |
| 400 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 401 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 403 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 404 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |
| 409 | ErrorResponse | Error response. A 403 from a permission check carries ForbiddenDetails in error.details. |

### Example

```bash
curl -X DELETE "$WITHHUMAN_URL/api/v1/api_keys/{id}/permission_policies/{policy_id}?revision=1" \
  -b "withhuman_session=$WITHHUMAN_SESSION" \
  -H "Idempotency-Key: string"
```
