API Keys API
Create and manage user-owned API keys for Agents, workflows, and uploads
API keys let server-side integrations, scripts, and iframe embeds call Clouisle without a user session. A key belongs to the user who created it — it is not a team resource: its reach is the combination of that user's roles, team memberships, and the key's own bindings. The base path is /api/v1/api-keys.
Key Shape and What Actually Gates Access
The server generates keys in this shape:
clou_a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890
└──┬─┘└───────────────────────────┬───────────────────────────┘
clou_ prefix 32 random bytes as 64 hex characters| Property | Value |
|---|---|
| Full length | 69 characters (clou_ + 64 hex) |
| Displayed prefix | The first 12 characters (clou_ + 8 hex), used for identification and search |
| Storage | Only a hash plus the prefix are stored; the full key appears once, in the create response |
| Owner | Bound to the creating user at creation time (user_id); it cannot be transferred |
Send it in the Authorization header, using the same Bearer form as a JWT:
Authorization: Bearer clou_a1b2c3d4e5f6...API key authentication checks exactly four things, and failing any of them rejects the request:
- Active state — a key with
is_active = falseno longer matches, and the request fails with HTTP401/2001(INVALID_TOKEN). - Expiry — a non-null
expires_atin the past fails with HTTP401/2002(TOKEN_EXPIRED); leaving it empty means the key never expires. - Owner account state — if the owning user is deactivated or pending approval, requests fail with HTTP
401/2004(INACTIVE_USER). - Resource bindings — when a key is bound to a list of Agents or Workflows it can only reach those; anything outside the list fails with HTTP
403/3000.
scopes and rate_limit are stored and echoed back, but the current version does not enforce either: there is no scope middleware and no per-key throttling. The four checks above are what actually applies. Never treat scopes as a security boundary, and do not rely on rate_limit for cost control.
Authentication and Permissions
Managing API keys requires a JWT user session. An API key cannot create, modify, or delete API keys.
| Permission | Allowed operations |
|---|---|
apikey:read | List keys, view one key, read statistics |
apikey:create | Create a key |
apikey:update | Update, activate, deactivate a key |
apikey:delete | Delete a key |
Beyond the permission code there is an ownership check: a non-superuser can only operate on keys they created, otherwise the request fails with HTTP 403 / 3000 (PERMISSION_DENIED). Superusers can see and operate on everyone's keys and bypass the permission-code check.
Endpoints
| Method | Path | Purpose | Permission |
|---|---|---|---|
| GET | /api/v1/api-keys | List API keys with pagination | apikey:read |
| GET | /api/v1/api-keys/stats | Aggregate statistics | apikey:read |
| POST | /api/v1/api-keys | Create an API key | apikey:create |
| GET | /api/v1/api-keys/{api_key_id} | Get one key | apikey:read |
| PUT | /api/v1/api-keys/{api_key_id} | Update a key | apikey:update |
| DELETE | /api/v1/api-keys/{api_key_id} | Delete (revoke) a key | apikey:delete |
| POST | /api/v1/api-keys/{api_key_id}/activate | Activate a key | apikey:update |
| POST | /api/v1/api-keys/{api_key_id}/deactivate | Deactivate a key | apikey:update |
Route order matters: /stats is a fixed path and /{api_key_id} expects a UUID, so /api/v1/api-keys/stats is never parsed as an ID.
List API Keys
GET /api/v1/api-keyscurl -X GET "https://your-domain.com/api/v1/api-keys?page=1&page_size=20" \
-H "Authorization: Bearer YOUR_TOKEN"| Parameter | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number, 1-based |
page_size | integer | 20 | Items per page |
status | string[] | — | Filter by active, inactive, or expired (repeatable; multiple values are OR-ed) |
user_id | UUID[] | — | Filter by owner; superuser only — ignored for regular users |
search | string | — | Case-insensitive match on name or key prefix |
The status definitions overlap:
active:is_active = trueand (expires_atis null or in the future).inactive:is_active = false(expired or not).expired:expires_atis in the past, even whenis_activeis stilltrue.
A deactivated key that has also expired therefore appears under both inactive and expired, so the counts can add up to more than the total.
Non-superusers always see only their own keys. Results are ordered by creation time, newest first.
Response (200 OK):
{
"code": 0,
"data": {
"items": [
{
"id": "8f14e45f-ceea-467a-9d0f-1a2b3c4d5e6f",
"name": "Production API Key",
"key_prefix": "clou_a1b2c3d",
"user_id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
"user": { "id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c", "username": "alice" },
"scopes": ["chat"],
"rate_limit": 1000,
"is_active": true,
"last_used_at": "2026-09-24T14:30:00Z",
"expires_at": "2027-02-11T00:00:00Z",
"agents": [{ "id": "0b1c2d3e-...", "name": "Customer Support", "icon": "🤖" }],
"workflows": [],
"created_at": "2026-02-11T10:00:00Z",
"updated_at": "2026-02-11T10:00:00Z"
}
],
"total": 1,
"page": 1,
"page_size": 20
},
"msg": "success"
}user is present only when the owner could be loaded. last_used_at is refreshed on every successful authenticated request.
Get an API Key
GET /api/v1/api-keys/{api_key_id}| Path parameter | Type | Required | Description |
|---|---|---|---|
api_key_id | string (UUID) | Yes | The API key UUID |
The body matches a list item (never including key). A missing key returns HTTP 404 / 4000; a key owned by someone else returns HTTP 403 / 3000.
Create an API Key
POST /api/v1/api-keys| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | Yes | — | Name, 1–100 characters |
scopes | string[] | No | ["chat"] | Permission scopes (stored only, never enforced) |
rate_limit | integer | No | 1000 | Requests per minute, 0 means unlimited (stored only, never enforced); must be ≥ 0 |
expires_at | string (ISO 8601) | No | null | Expiry time; omit or pass null for a key that never expires |
agent_ids | UUID[] | No | [] | Agents this key may access; an empty array means no restriction |
workflow_ids | UUID[] | No | [] | Workflows this key may access; an empty array means no restriction |
curl -X POST "https://your-domain.com/api/v1/api-keys" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Production API Key",
"expires_at": "2027-02-11T00:00:00Z"
}'Bindings are validated at creation time: every entry in agent_ids / workflow_ids goes through the Agent/Workflow access check, so naming a resource that does not exist or that you cannot access fails immediately (HTTP 404 / 4000 or 403 / 3000). Authorization and binding are therefore the same step.
Response (200 OK):
{
"code": 0,
"data": {
"id": "8f14e45f-ceea-467a-9d0f-1a2b3c4d5e6f",
"name": "Production API Key",
"key": "clou_a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4e5f67890",
"key_prefix": "clou_a1b2c3d",
"user_id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
"scopes": ["chat"],
"rate_limit": 1000,
"is_active": true,
"expires_at": "2027-02-11T00:00:00Z",
"last_used_at": null,
"agents": [],
"workflows": [],
"created_at": "2026-02-11T16:00:00Z",
"updated_at": "2026-02-11T16:00:00Z"
},
"msg": "API key created successfully"
}data.key appears in this response only. The server keeps just a hash, so every later read returns only key_prefix. Write it to a secret manager or environment variable immediately; if you lose it, delete the key and create a new one.
Update an API Key
PUT /api/v1/api-keys/{api_key_id}Every field is optional; only the fields you send change, and omitted fields keep their current value.
| Field | Type | Description |
|---|---|---|
name | string | Name, 1–100 characters |
scopes | string[] | Replaces the scope list |
rate_limit | integer | Replaces the rate-limit value; must be ≥ 0 |
expires_at | string (ISO 8601) | Replaces the expiry time |
is_active | boolean | Flips the active state directly |
agent_ids | UUID[] | Replaces the Agent bindings entirely; [] clears them (cleared means no restriction) |
workflow_ids | UUID[] | Replaces the Workflow bindings entirely; [] clears them |
agent_ids / workflow_ids are replacement semantics, not incremental appends. To add one binding, first read the current bindings (GET the key) and submit the full list, otherwise the remaining bindings are silently dropped. Only null or an omitted field means “leave unchanged”.
The response returns the full updated object with msg set to API key updated successfully.
Delete an API Key
DELETE /api/v1/api-keys/{api_key_id}Deletion takes effect immediately: the key no longer authenticates (later requests return HTTP 401 / 2001). data in the response is the deleted key object (returned for audit echo), not null.
Deletion is irreversible — there is no soft delete or grace period. When rotating, create the replacement, confirm it works, then delete the old key.
Activate and Deactivate
POST /api/v1/api-keys/{api_key_id}/activate
POST /api/v1/api-keys/{api_key_id}/deactivateBoth return the updated key object. Calling either while already in the target state is an error, which also helps detect concurrent edits:
activateon an active key: HTTP400/1002(api_key_already_active).deactivateon an inactive key: HTTP400/1002(api_key_already_inactive).
Deactivation is a reversible pause: the key record and its bindings stay intact, and reactivating restores exactly the previous access.
Statistics
GET /api/v1/api-keys/statsRegular users get statistics for their own keys; superusers get deployment-wide numbers.
{
"code": 0,
"data": { "total": 3, "active": 2, "inactive": 0, "expired": 1 },
"msg": "success"
}The definitions match the list status filter: active requires both an active flag and a non-expired key, inactive looks only at the flag, and expired looks only at the expiry time. Because the definitions overlap, the four numbers do not have to add up to total.
Error Codes
| HTTP | Code | Scenario |
|---|---|---|
400 | 1001 | Body validation failed (empty name, negative rate_limit) |
400 | 1002 | The key is already in the requested state (duplicate activate/deactivate) |
401 | 2000 | No credentials provided |
401 | 2001 | API key invalid, deleted, or deactivated; JWT invalid or revoked |
401 | 2002 | API key expired |
401 | 2004 | The key's owner is deactivated or pending approval |
403 | 2003 | JWT cannot be decoded or has expired |
403 | 3000 | Missing an apikey:* permission, operating on someone else's key, or a binding mismatch |
404 | 4000 | api_key_id does not exist |
Scope Values
scopes is a free-form string array: the server validates no enumeration and enforces nothing. To stay compatible with a future version, these are the values seen in the UI and community examples:
| Value | Meaning |
|---|---|
chat | Call Agent chat endpoints (the creation default) |
agent:read | Read Agent information |
agent:chat | Chat with an Agent |
workflow:execute | Execute a workflow |
workflow:run | Trigger a workflow run |
kb:read | Read knowledge base content |
If you need least privilege today, the only reliable mechanism is resource binding: restrict agent_ids / workflow_ids to the resources you actually call, and deactivate keys you no longer need. Scope strings are documentation and audit trail only.
Best Practices
Security
- Keep full keys in environment variables or a secret manager — never in version control, logs, or client-side code.
- One key per integration so a single deactivation does not break every caller.
- Set
expires_aton production keys and allow an overlap window when rotating. - Use
agent_ids/workflow_idsto limit a key to the resources it needs. - Review
last_used_atregularly and delete keys that are no longer used. - Respond to a leak with
DELETE(full revoke) ordeactivate(temporary pause).
Lifecycle management
- Name keys with their purpose and environment, for example
prod-chatbot-gateway. - Rotation flow: create the new key → update the caller → watch
last_used_atstop advancing → delete the old key. - Prefer
deactivatefor temporary investigations; reactivating is cheaper than redistributing a new secret. - Re-check keys after team membership changes: a key assumes its owner's identity, so an owner leaving a team changes what the key can reach.
Code Examples
Python
import os
import requests
BASE = "https://your-domain.com/api/v1"
def create_api_key(token: str, name: str, agent_ids: list[str] | None = None) -> dict:
"""Create an API key bound to specific Agents."""
response = requests.post(
f"{BASE}/api-keys",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
json={
"name": name,
"expires_at": "2027-02-11T00:00:00Z",
"agent_ids": agent_ids or [],
},
)
result = response.json()
if result["code"] != 0:
raise RuntimeError(f"create failed: {result['code']} {result['msg']}")
# data["key"] is available only in this one response
print(f"created {result['data']['key_prefix']}…")
return result["data"]
def list_api_keys(token: str, status: str = "active") -> dict:
"""Page through your own API keys, filtered by status."""
response = requests.get(
f"{BASE}/api-keys",
headers={"Authorization": f"Bearer {token}"},
params={"page": 1, "page_size": 20, "status": status},
)
return response.json()["data"]
# Usage
token = os.environ["USER_TOKEN"]
new_key = create_api_key(token, "prod-chatbot")
print("store the full key in your secret manager:", new_key["key"])JavaScript
const BASE = 'https://your-domain.com/api/v1';
async function createApiKey(token, name, agentIds = []) {
const response = await fetch(`${BASE}/api-keys`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
expires_at: '2027-02-11T00:00:00Z',
agent_ids: agentIds,
}),
});
const result = await response.json();
if (result.code !== 0) {
throw new Error(`create failed: ${result.code} ${result.msg}`);
}
return result.data; // data.key is available only in this one response
}
async function deactivateApiKey(token, keyId) {
const response = await fetch(`${BASE}/api-keys/${keyId}/deactivate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
const result = await response.json();
if (result.code !== 0) {
throw new Error(`deactivate failed: ${result.code} ${result.msg}`);
}
return result.data;
}
// Usage
const token = process.env.USER_TOKEN;
const created = await createApiKey(token, 'prod-chatbot');
console.log('the full key is only available in this response:', created.key);Related Documentation
- API Quick Start — send your first request with a key
- Authentication & Login API — obtain the JWT session needed to manage keys
- API Errors and Retries — unified handling for
2001,2002, and3000
How is this guide?