API Reference
Developer Console API

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
}
FieldRules
nametrimmed 1–100 chars
client_typepublic | confidential (empty string treated as public)
redirect_uris1–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 URLsoptional 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 | production

Publishing 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 } }
FieldCounts
connected_usersDistinct users with a stored consent
active_grantsNon-revoked refresh-token families
live_access_tokensUnexpired, 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" }