Developer Console API
Self-service management of projects and OAuth clients. Every route requires the session cookie of a signed-in user (the same identity as the Cockpit); unauthenticated calls get 401 not authenticated.
All mutations append to a per-project audit trail. Quotas: 25 projects per owner, 25 clients per project.
Projects
POST /console/projects
Rate limit: 5/hour/IP
{ "name": "Bajeti" }201:
{
"success": true,
"data": {
"id": "9f8e7d6c-…",
"name": "Bajeti",
"slug": "bajeti",
"created_at": "2026-08-26T09:30:00Z",
"archived": false
}
}Rules: name trimmed, 1–100 chars (400 name must be between 1 and 100 characters); quota → 400 quota exceeded: project limit of 25 reached. Slugs are auto-generated (≤40 chars) with -2, -3 suffixes on collision — client IDs derive from them.
GET /console/projects · GET …/{project_id}
List (archived included) / fetch one. Ownership is enforced silently — another owner's project ID behaves as 404 project not found.
PATCH /console/projects/{project_id}
{ "name": "Bajeti Platform" }→ { "success": true, "message": "project renamed" } · archived project → 403 this project is archived.
DELETE /console/projects/{project_id}
Soft-archive; every client inside becomes disabled atomically.
→ { "success": true, "message": "project archived — all its clients are disabled" } (repeat archives report project already archived).
GET /console/projects/{project_id}/audit-logs
Newest first, capped at 100:
[
{
"id": 42,
"action": "client.created",
"target_type": "client",
"target_id": "bajeti-web-x7k2p9",
"metadata": { "name": "Bajeti Web" },
"ip_address": "41.86.176.12",
"created_at": "2026-08-26T09:31:12Z"
}
](metadata and ip_address omitted when not applicable.)
Clients
POST /console/projects/{project_id}/clients
Rate limit: 20/hour/IP
{
"name": "Bajeti Web",
"client_type": "public",
"redirect_uris": ["https://bajeti.cameltech.co/callback"],
"allowed_scopes": ["openid", "profile", "email"],
"logo_url": null,
"homepage_url": null,
"privacy_policy_url": null
}| Field | Rules |
|---|---|
name | trimmed 1–100 chars |
client_type | public | confidential (empty string treated as public) |
redirect_uris | 1–20 URIs, each ≤512 chars, absolute scheme://…, scheme = https/http/custom app scheme; no wildcards |
allowed_scopes | ≥1 scope from openid profile email phone |
| branding URLs | optional strings |
201 — new clients start in testing; ID auto-generated <project-slug>-<6 chars>:
{
"success": true,
"data": {
"client": {
"id": "bajeti-web-x7k2p9",
"name": "Bajeti Web",
"type": "public",
"status": "testing",
"redirect_uris": ["https://bajeti.cameltech.co/callback"],
"allowed_scopes": ["openid", "profile", "email"],
"has_secret": false,
"created_at": "2026-08-26T09:31:12Z"
},
"client_secret": null
}
}For confidential clients client_secret carries the one-time plaintext:
"client_secret": "cask_9tX4kPqWmE7rJ2nF6hD0sY5uLbGzA1cV8oN4iK3xMwT"Secret format: literal prefix cask_ + 43 base64url chars (48 total), stored server-side only as sha256$<hex> — shown exactly once at creation/rotation.
GET …/clients · GET …/clients/{client_id}
List / fetch within this project (same ownership walls).
PATCH …/clients/{client_id}
Partial merge semantics: omitting redirect_uris/allowed_scopes leaves them untouched; sending an empty array clears them.
{
"status": "production",
"homepage_url": "https://bajeti.cameltech.co",
"redirect_uris": [
"https://bajeti.cameltech.co/callback",
"https://staging.bajeti.cameltech.co/callback"
]
}Status machine:
testing ⇄ testing testing → production | disabled
production → production | disabled
disabled → testing | productionPublishing gate: any move to production requires ≥1 https:// redirect URI and a homepage_url, else
400 publishing requires at least one https:// redirect_uri and a homepage_url.
Disabled clients are refused at /authorize, consent, and token endpoints.
POST …/clients/{client_id}/rotate-secret
Confidential only — 400 public clients have no secret to rotate otherwise. Old secret dies instantly.
{
"success": true,
"data": {
"client": { "...": "…" },
"client_secret": "cask_NewValue43charsBase64urlPaddingFreeX",
"note": "copy it now — it will not be shown again; the previous secret is already invalid"
}
}GET …/clients/{client_id}/stats
Live usage counters for one client:
{ "success": true, "data": { "connected_users": 1284, "active_grants": 1290, "live_access_tokens": 37 } }| Field | Counts |
|---|---|
connected_users | Distinct users with a stored consent |
active_grants | Non-revoked refresh-token families |
live_access_tokens | Unexpired, non-revoked access tokens |
DELETE …/clients/{client_id}
Hard delete with full cascade — refresh families, grants, and live access tokens die instantly across all users:
{ "success": true, "message": "client deleted — all its tokens and grants are revoked" }