API keys
Create, list, rotate, disable, and delete a project's inference API keys.
Project API keys are the credentials your applications use on the Inference API. This page manages their whole lifecycle — provision · configure · rotate · retire — with the same semantics as the portal, and every write is recorded in the audit log with the token that made it.
| Endpoint | Scope |
|---|---|
GET /projects/{id}/keys | keys:read |
POST /projects/{id}/keys | keys:write |
GET /keys/{id} | keys:read |
PATCH /keys/{id} | keys:write |
POST /keys/{id}/rotate | keys:write |
POST /keys/{id}/rotate/cancel | keys:write |
POST /keys/{id}/disable | keys:write |
POST /keys/{id}/enable | keys:write |
DELETE /keys/{id} | keys:write |
Keys are created under a project and addressed flat by id afterwards.
Unlike project, member and organization writes, key routes are not
Admin-only. They follow the portal's rules for the token owner: an Admin
manages every key; a Member sees and manages only the keys they created,
and creates keys only in projects they belong to. Anything else is
403 insufficient_scope (the message says which rule) or 404.
The Python examples share this setup:
import os
import uuid
import requests
BASE = "https://portal.<domain>/api/v1/mgmt"
s = requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['TF_PAT']}"
def idem():
return {"Idempotency-Key": str(uuid.uuid4())}The key object
| Field | Type | Notes |
|---|---|---|
id | string | Opaque id. |
project_id | string | The owning project. |
name | string | |
description | string | null | |
key_prefix | string | Masked form for display, e.g. tf-4b8a9c-7d21e0-Qm8...k2Zw. Never the secret. |
status | string | active, disabled or revoked. |
created_at | string | ISO-8601 UTC. |
last_used_at | string | null | |
expires_at | string | null | null = no expiry. |
revoked_at | string | null | Set once the key is deleted. |
requests_7d | integer | Requests in the last 7 days. |
allowed_models | string[] | Models this key may call (provider/model); empty = the project's allowlist applies. |
rpm_limit | integer | null | The key's own requests-per-minute limit. |
spend_cap | object | null | Legacy, read-only: a key-level spend cap stored before spend limits moved to the project — { "amount_usd", "reset_period" }, where reset_period is daily, weekly, monthly or never. null = no key cap. It cannot be set or changed through this API; set spend limits on the project. |
ip_allowlist | string[] | CIDR ranges; empty = the project's allowlist applies. |
rotation | object | null | Present while a rotation is in progress: { "grace_expires_at": "…" } — the previous secret keeps working until then. |
created_by | object | null | { "id", "email" } of the creator. Immutable history — it persists after the creator leaves, and nothing reassigns it. |
{
"id": "c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74",
"project_id": "5b3e9a41-0c7d-4e2f-8a16-9d4b7c2e1f08",
"name": "payments-prod",
"description": null,
"key_prefix": "tf-4b8a9c-7d21e0-Qm8...k2Zw",
"status": "active",
"created_at": "2026-09-20T08:20:44Z",
"last_used_at": "2026-09-24T09:58:12Z",
"expires_at": "2027-03-31T00:00:00Z",
"revoked_at": null,
"requests_7d": 48213,
"allowed_models": [],
"rpm_limit": null,
"spend_cap": null,
"ip_allowlist": [],
"rotation": null,
"created_by": { "id": "8e7d6c5b-4a39-4281-9f0e-1d2c3b4a5968", "email": "[email protected]" }
}List a project's keys
GET /api/v1/mgmt/projects/{id}/keysReturns the project's keys (masked), newest first — including revoked keys,
for the record. A Member sees only the keys they created.
Paginated with limit and cursor.
| Query parameter | Notes |
|---|---|
status | active, disabled or revoked. Any other value → 422 validation_failed. |
curl -G https://portal.<domain>/api/v1/mgmt/projects/5b3e9a41-0c7d-4e2f-8a16-9d4b7c2e1f08/keys \
-H "Authorization: Bearer $TF_PAT" \
-d status=activeCreate a key
POST /api/v1/mgmt/projects/{id}/keys| Body field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Unique among your active keys (409 name_taken). |
description | string | no | |
allowed_models | string[] | no | provider/model ids. An unknown model → 400, param: "allowed_models". |
rpm_limit | integer | no | At least 1. |
expires_at | string | no | RFC 3339 timestamp. |
ip_allowlist | string[] | no | CIDR notation. |
Requires Idempotency-Key. Returns 201 with the key object plus
secret — the full key value.
Spend limits are set on the project
(spend_limit_usd), not on keys. A body carrying spend_cap_amount,
spend_cap_reset_period or clear_spend_cap — any value, including null
— is rejected with 400 invalid_request, param naming the field.
Warning
The secret is returned once, in this response only. Every later read
returns the masked key_prefix. If the response is lost, retry with the same Idempotency-Key within 24 hours (the
replay returns the same secret and is audited as key_create_replayed) or
rotate the key. Write the secret straight to your secret store; log the key
id, never the value.
curl -X POST https://portal.<domain>/api/v1/mgmt/projects/5b3e9a41-0c7d-4e2f-8a16-9d4b7c2e1f08/keys \
-H "Authorization: Bearer $TF_PAT" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: run-48213-create-key" \
-d '{"name": "payments-prod", "expires_at": "2027-03-31T00:00:00Z"}'{
"id": "c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74",
"project_id": "5b3e9a41-0c7d-4e2f-8a16-9d4b7c2e1f08",
"name": "payments-prod",
"description": null,
"key_prefix": "tf-4b8a9c-7d21e0-Qm8...k2Zw",
"status": "active",
"created_at": "2026-09-24T10:02:40Z",
"last_used_at": null,
"expires_at": "2027-03-31T00:00:00Z",
"revoked_at": null,
"requests_7d": 0,
"allowed_models": [],
"rpm_limit": null,
"spend_cap": null,
"ip_allowlist": [],
"rotation": null,
"created_by": { "id": "8e7d6c5b-4a39-4281-9f0e-1d2c3b4a5968", "email": "[email protected]" },
"secret": "tf-4b8a9c-7d21e0-Qm8xR2v…(full value, shown once)"
}Retrieve a key
GET /api/v1/mgmt/keys/{id}Returns the key object (masked).
curl https://portal.<domain>/api/v1/mgmt/keys/c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74 \
-H "Authorization: Bearer $TF_PAT"Update a key
PATCH /api/v1/mgmt/keys/{id}Changes configuration only. Send at least one field; only the fields you
send change. Returns 200 with the key object. Key-level spend-cap fields
are rejected exactly as on create — set spend limits on the project.
| Body field | Type | Notes |
|---|---|---|
name | string | |
expires_at | string | null | RFC 3339 timestamp; null removes the expiry. |
ip_allowlist | string[] | null | CIDR notation; [] or null = no key-level restriction. |
State has its own verbs (disable, enable, delete) — PATCH never
changes state.
curl -X PATCH https://portal.<domain>/api/v1/mgmt/keys/c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74 \
-H "Authorization: Bearer $TF_PAT" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f" \
-d '{"name": "payments-prod-eu", "ip_allowlist": ["198.51.100.0/24"]}'Rotate a key
POST /api/v1/mgmt/keys/{id}/rotateIssues a new secret for the same key id. The previous secret keeps working for a grace window you choose, so you can deploy the new one without downtime.
| Body field | Type | Required | Notes |
|---|---|---|---|
grace_hours | integer | no | How long the previous secret keeps working: 1–168 (7 days). Default 24. The body may be omitted. |
Returns 200 with the key object plus secret (the new value, once).
The old secret stops working at rotation.grace_expires_at. A
replay with the same Idempotency-Key returns the same secret and is
audited as key_rotate_replayed.
curl -X POST https://portal.<domain>/api/v1/mgmt/keys/c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74/rotate \
-H "Authorization: Bearer $TF_PAT" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: run-48213-rotate-key" \
-d '{"grace_hours": 2}'{
"id": "c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74",
"project_id": "5b3e9a41-0c7d-4e2f-8a16-9d4b7c2e1f08",
"name": "payments-prod",
"key_prefix": "tf-4b8a9c-7d21e0-Zt4...9pQa",
"status": "active",
"rotation": { "grace_expires_at": "2026-09-24T12:05:00Z" },
"secret": "tf-4b8a9c-7d21e0-Zt4mW8e…(full value, shown once)",
"...": "..."
}Warning
If the grace window ends before the new secret is deployed, calls with the
old secret fail with 401. This is the most common self-inflicted incident
in API-driven key management — size grace_hours to your rollout and
watch for keys stuck mid-rotation.
Cancel a rotation
POST /api/v1/mgmt/keys/{id}/rotate/cancelAborts an in-progress rotation: the new secret is discarded and the
original secret stays active with no deadline. This is the clean abort for a
failed deploy. No body. Returns 200 with the key object (rotation: null). A key with no rotation in progress is 409 no_rotation_in_progress.
curl -X POST https://portal.<domain>/api/v1/mgmt/keys/c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74/rotate/cancel \
-H "Authorization: Bearer $TF_PAT" \
-H "Idempotency-Key: run-48213-cancel-rotation"Disable a key
POST /api/v1/mgmt/keys/{id}/disableReversible: the key stops authenticating inference until you enable it. No
body. Returns 200 with the key object (status: "disabled").
curl -X POST https://portal.<domain>/api/v1/mgmt/keys/c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74/disable \
-H "Authorization: Bearer $TF_PAT" \
-H "Idempotency-Key: 3d4e5f6a-7b8c-4d9e-8f0a-1b2c3d4e5f6a"Enable a key
POST /api/v1/mgmt/keys/{id}/enableThe inverse of disable. No body. Returns 200 with the key object
(status: "active"). A deleted (revoked) key cannot come back:
409 key_revoked.
curl -X POST https://portal.<domain>/api/v1/mgmt/keys/c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74/enable \
-H "Authorization: Bearer $TF_PAT" \
-H "Idempotency-Key: 4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b"Delete a key
DELETE /api/v1/mgmt/keys/{id}The permanent kill. The key stops authenticating and cannot be
restored; it stays readable with status: "revoked" and a revoked_at, and
its usage history and audit trail are retained. A compromised secret gets
no grace window — use this for a confirmed leak. Returns 204 No Content.
| Body field | Type | Required | Notes |
|---|---|---|---|
confirmed_leak | boolean | no | true also cuts off requests already streaming on the key. Default false. The body may be omitted. |
curl -X DELETE https://portal.<domain>/api/v1/mgmt/keys/c41d8e2a-7b90-4f3c-a6e5-2d1f0b9c8a74 \
-H "Authorization: Bearer $TF_PAT" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: incident-4711-delete-key" \
-d '{"confirmed_leak": true}'Note
To retire every key a departing member created,
either remove the member with
DELETE /members/{id} and
"delete_keys": true, or list the project's keys, filter on
created_by.id, and delete or rotate each by id.
Errors specific to keys
| Status | Code | When |
|---|---|---|
| 400 | invalid_request (param set) | Missing name; unknown field; grace_hours outside 1–168; bad expires_at; bad CIDR; a key-level spend-cap field (spend_cap_amount, spend_cap_reset_period, clear_spend_cap — spend limits are set on the project); an unknown model in allowed_models. |
| 403 | insufficient_scope | Token lacks keys:read/keys:write; a Member owner acting on a key they did not create, or in a project they do not belong to. |
| 404 | not_found | Key or project absent, or in another organization. |
| 409 | name_taken | You already have an active key with this name. |
| 409 | key_revoked | Enabling a deleted key. |
| 409 | no_rotation_in_progress | Cancelling when no rotation is in progress. |
| 409 | idempotency_conflict | The Idempotency-Key was used for a different request. |
| 422 | unsupported_filter · validation_failed | An unknown query parameter, or a status filter value outside the list above. |