REST API reference

The CloudSmith API is a .NET 9 Minimal API served at port 8080 inside the cloudsmith-api container. In the default on-premises configuration it is proxied through the management host on port 443 alongside the portal. In the Azure PaaS configuration it is exposed as a separate Azure Container App.

All API paths are prefixed with /api/v1.


Health endpoints

The health endpoints are unauthenticated and are used by load balancers, container orchestrators, and monitoring systems to verify the API process is alive and ready to serve traffic.

GET /health/live

Liveness probe — returns 200 if the API process is running. Does not check database connectivity.

Response — 200 OK:

{ "status": "ok" }

GET /health/ready

Readiness probe — returns 200 if the API is ready to serve requests (database connected, migrations complete).

Response — 200 OK:

{ "status": "ok" }

Response — 503 Service Unavailable: Returned during startup or if the database is unreachable.


GET /health/startup

Startup probe — returns 200 once the API has completed its initialization sequence (including first-run setup gate). Used by ACA startup probes to delay traffic until the API is fully initialized.

Response — 200 OK:

{ "status": "ok" }

Response — 503 Service Unavailable: Returned if startup is still in progress.


Setup endpoints

The setup endpoints are active only when the platform has not yet been initialized. After the first-run wizard is completed, all setup endpoints return 403.

GET /api/v1/setup/status

Returns the current setup state of the platform.

Authentication: None required.

Response — 200 OK (setup not yet complete):

{
  "setupComplete": false,
  "wizardStep": "welcome"
}

Response — 200 OK (setup complete):

{
  "setupComplete": true
}

POST /api/v1/setup/complete

Complete the first-run setup. Creates the platform organisation and the initial administrator account.

Authentication: Requires a valid initial admin token (retrieved from Key Vault on PaaS, or from the installer output on-premises).

Request body:

{
  "platformName": "CloudSmith",
  "publicUrl": "https://cloudsmith.contoso.com",
  "timezone": "America/Chicago",
  "adminUsername": "admin",
  "adminEmail": "admin@example.com",
  "adminPassword": "your-strong-password",
  "initialAdminToken": "<token-from-key-vault-or-installer>"
}
Field Required Notes
platformName Yes Display name for the platform
publicUrl Yes Portal HTTPS URL — used for OIDC redirect URIs
timezone Yes IANA timezone identifier
adminUsername Yes Local administrator username
adminEmail Yes Local administrator email
adminPassword Yes Minimum 8 characters
initialAdminToken Yes One-time bootstrap token; expires 24 hours after first API start

Response — 200 OK:

{
  "accessToken": "eyJhbGci...",
  "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2g...",
  "expiresIn": 900,
  "tokenType": "Bearer"
}

The response is a valid access token — the caller is signed in as the new administrator immediately.

Error — 422 Unprocessable Entity: "A valid initial admin token is required." The howToRetrieve field in the error body provides the exact az keyvault secret show command to retrieve the token.

Error — 422 Unprocessable Entity: "The initial admin token has expired (24-hour TTL)." Restart the API container twice to mint a fresh token (see PaaS install — Step 5).


Authentication

All endpoints except the health and setup endpoints, and /api/v1/auth/local-login, require a Bearer token in the Authorization header:

Authorization: Bearer <access_token>

Tokens are issued by POST /api/v1/auth/login and refreshed by POST /api/v1/auth/refresh. Tokens expire after 15 minutes; refresh tokens are valid for 7 days.


Auth endpoints

POST /api/v1/auth/local-login

Authenticate with a local CloudSmith account (username/password) and receive an access token. Use this endpoint for automation and CLI integrations. For browser-based portal access, the portal handles authentication automatically via the session flow.

Request body:

{
  "username": "admin",
  "password": "your-password"
}

Response — 200 OK:

{
  "accessToken": "eyJhbGci...",
  "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2g...",
  "expiresIn": 900,
  "tokenType": "Bearer"
}

Response — 401 Unauthorized:

{
  "error": "invalid_credentials",
  "message": "Username or password is incorrect."
}

curl example:

curl -s -X POST https://<host>/api/v1/auth/local-login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"your-password"}' | jq .

GET /api/v1/auth/me

Return the profile of the currently authenticated user.

Required permission: Authenticated user (any role)

Response — 200 OK:

{
  "id": "user_01...",
  "username": "admin",
  "email": "admin@example.com",
  "roles": ["PlatformAdmin"],
  "organisationId": "org_01...",
  "authProvider": "local",
  "createdAt": "2026-05-01T10:00:00Z"
}

POST /api/v1/auth/login

Authenticate with a local account and receive an access token. Equivalent to /api/v1/auth/local-login; both paths are supported.

Request body:

{
  "username": "admin",
  "password": "your-password"
}

Response — 200 OK:

{
  "accessToken": "eyJhbGci...",
  "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2g...",
  "expiresIn": 900,
  "tokenType": "Bearer"
}

Response — 401 Unauthorized:

{
  "error": "invalid_credentials",
  "message": "Username or password is incorrect."
}

curl example:

curl -s -X POST https://<host>/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"your-password"}' | jq .

POST /api/v1/auth/refresh

Exchange a refresh token for a new access token.

Request body:

{
  "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2g..."
}

Response — 200 OK:

{
  "accessToken": "eyJhbGci...",
  "refreshToken": "bGFzdCByZWZyZXNo...",
  "expiresIn": 900,
  "tokenType": "Bearer"
}

Response — 401 Unauthorized: Refresh token has expired or been revoked. Re-authenticate via /api/v1/auth/login.

curl example:

curl -s -X POST https://<host>/api/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refreshToken":"<refresh_token>"}' | jq .

POST /api/v1/auth/logout

Revoke the current refresh token. The access token remains valid until it expires (maximum 15 minutes).

Request body: empty

Required permission: Authenticated user (any role)

Response — 204 No Content

curl example:

curl -s -X POST https://<host>/api/v1/auth/logout \
  -H "Authorization: Bearer $TOKEN"

Cluster endpoints

GET /api/v1/clusters

List all registered clusters visible to the authenticated user.

Required permission: clusters:read

Query parameters:

Parameter Type Description
status string Filter by status: Healthy, Degraded, Pending, Offline
type string Filter by cluster type: HyperV, AzureLocal
page int Page number (1-based, default 1)
pageSize int Results per page (default 20, max 100)

Response — 200 OK:

{
  "items": [
    {
      "id": "clus_01hv7...",
      "name": "prod-hci-01",
      "type": "HyperV",
      "status": "Healthy",
      "nodeCount": 3,
      "registeredAt": "2026-05-01T10:00:00Z",
      "lastSeenAt": "2026-05-27T08:00:00Z"
    }
  ],
  "totalCount": 1,
  "page": 1,
  "pageSize": 20
}

curl example:

curl -s https://<host>/api/v1/clusters \
  -H "Authorization: Bearer $TOKEN" | jq .

POST /api/v1/clusters

Register a new cluster.

Required permission: clusters:write

Request body:

{
  "name": "prod-hci-01",
  "type": "HyperV",
  "siteId": "site_01hz..."
}
Field Type Required Description
name string Yes Display name for the cluster (3–64 characters)
type string Yes HyperV or AzureLocal
siteId string No ID of an existing site to associate the cluster with

Response — 201 Created:

{
  "id": "clus_01hv7...",
  "name": "prod-hci-01",
  "type": "HyperV",
  "status": "Pending",
  "enrollmentToken": "enroll_01hz...",
  "relayCommand": "docker run -d --name cloudsmith-relay ...",
  "registeredAt": "2026-05-27T08:00:00Z"
}

The enrollmentToken is single-use and expires after 24 hours. Use it to enroll the Relay service.

curl example:

curl -s -X POST https://<host>/api/v1/clusters \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"prod-hci-01","type":"HyperV"}' | jq .

GET /api/v1/clusters/{id}

Get a specific cluster by ID.

Required permission: clusters:read

Response — 200 OK:

{
  "id": "clus_01hv7...",
  "name": "prod-hci-01",
  "type": "HyperV",
  "status": "Healthy",
  "nodeCount": 3,
  "nodes": [
    { "id": "node_01...", "name": "hv-node-01", "status": "Online" },
    { "id": "node_02...", "name": "hv-node-02", "status": "Online" },
    { "id": "node_03...", "name": "hv-node-03", "status": "Online" }
  ],
  "relay": {
    "id": "relay_01...",
    "displayName": "relay-prod-hci-01",
    "status": "Connected",
    "lastHeartbeatAt": "2026-05-27T07:59:30Z"
  },
  "registeredAt": "2026-05-01T10:00:00Z",
  "lastSeenAt": "2026-05-27T08:00:00Z"
}

Response — 404 Not Found: Cluster ID does not exist or is not accessible to this user.


PATCH /api/v1/clusters/{id}

Update cluster name or site association.

Required permission: clusters:write

Request body (all fields optional):

{
  "name": "prod-hci-01-renamed",
  "siteId": "site_02..."
}

Response — 200 OK: Returns the updated cluster object.


DELETE /api/v1/clusters/{id}

Deregister a cluster. This removes the cluster and all associated data from CloudSmith. It does not affect the cluster nodes themselves.

Required permission: clusters:admin

Response — 204 No Content

Response — 409 Conflict: The cluster has active jobs. Cancel or wait for them to complete before deleting.


Job endpoints

POST /api/v1/jobs

Submit a job for execution against a cluster.

Required permission: jobs:write

Request body:

{
  "clusterId": "clus_01hv7...",
  "type": "InvokeScript",
  "payload": {
    "script": "Get-VM | Select-Object Name, State",
    "targetNodeIds": ["node_01...", "node_02..."]
  }
}

Response — 202 Accepted:

{
  "id": "job_01hz...",
  "clusterId": "clus_01hv7...",
  "type": "InvokeScript",
  "status": "Queued",
  "createdAt": "2026-05-27T08:00:00Z"
}

curl example:

curl -s -X POST https://<host>/api/v1/jobs \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"clusterId":"clus_01hv7...","type":"InvokeScript","payload":{"script":"Get-VM"}}' | jq .

GET /api/v1/jobs/{id}

Get the status and result of a specific job.

Required permission: jobs:read

Response — 200 OK:

{
  "id": "job_01hz...",
  "clusterId": "clus_01hv7...",
  "type": "InvokeScript",
  "status": "Completed",
  "result": {
    "output": "Name       State\n----       -----\nvm-01      Running",
    "exitCode": 0
  },
  "createdAt": "2026-05-27T08:00:00Z",
  "startedAt": "2026-05-27T08:00:01Z",
  "completedAt": "2026-05-27T08:00:03Z"
}

Job status values: Queued, Running, Completed, Failed, Cancelled.


POST /api/v1/jobs/batch

Submit up to 500 jobs in a single request.

Required permission: jobs:write

Request body:

{
  "jobs": [
    {
      "clusterId": "clus_01hv7...",
      "type": "InvokeScript",
      "payload": { "script": "Get-VM" }
    },
    {
      "clusterId": "clus_02...",
      "type": "InvokeScript",
      "payload": { "script": "Get-ClusterNode" }
    }
  ]
}

Maximum 500 jobs per request. Exceeding this limit returns 400 with error: "batch_limit_exceeded".

Response — 202 Accepted:

{
  "batchId": "batch_01...",
  "jobIds": ["job_01...", "job_02..."],
  "submittedCount": 2,
  "createdAt": "2026-05-27T08:00:00Z"
}

Each job in the batch is independently tracked via GET /api/v1/jobs/{id}.

Note: Batch jobs are scoped to the submitting user’s organisation (org_id). Jobs submitted for clusters outside the user’s organisation are rejected with 403.


Module endpoints

GET /api/v1/modules/catalog

List available modules from the CloudSmith module catalog.

Required permission: modules:read

Query parameters:

Parameter Type Description
installed boolean If true, return only installed modules
verified boolean If true, return only cosign-verified modules
search string Full-text search across name and description

Response — 200 OK:

{
  "items": [
    {
      "id": "monitoring",
      "name": "CloudSmith Monitoring",
      "version": "1.2.0",
      "description": "Prometheus-based workload monitoring for Hyper-V clusters.",
      "verified": true,
      "installed": false,
      "updateAvailable": false,
      "catalogSource": "ghcr.io/cloudsmith-cloud/module-monitoring"
    }
  ],
  "totalCount": 1
}

POST /api/v1/modules/{id}/install

Install a module from the catalog.

Required permission: modules:install (implies platform:admin)

Path parameter: id — module ID from the catalog (e.g., monitoring)

Request body:

{
  "version": "1.2.0"
}

Omit version to install the latest verified release.

Response — 202 Accepted:

{
  "jobId": "job_01hz...",
  "moduleId": "monitoring",
  "version": "1.2.0",
  "status": "Installing"
}

Module installation is asynchronous. Poll GET /api/v1/jobs/{jobId} for status.


DELETE /api/v1/modules/{id}

Uninstall an installed module.

Required permission: modules:install (implies platform:admin)

Response — 202 Accepted:

{
  "jobId": "job_01hz...",
  "moduleId": "monitoring",
  "status": "Uninstalling"
}

Notification endpoints

GET /api/v1/notifications

List notifications for the authenticated user.

Required permission: Authenticated user (any role)

Query parameters:

Parameter Type Description
unreadOnly boolean If true, return only unread notifications
page int Page number (1-based, default 1)
pageSize int Results per page (default 20, max 100)

Response — 200 OK:

{
  "items": [
    {
      "id": "notif_01...",
      "type": "ClusterHealthDegraded",
      "title": "Cluster prod-hci-01 is degraded",
      "body": "Node hv-node-03 has gone offline.",
      "read": false,
      "createdAt": "2026-05-27T07:55:00Z"
    }
  ],
  "unreadCount": 1,
  "totalCount": 1
}

PATCH /api/v1/notifications

Mark notifications as read.

Required permission: Authenticated user (any role)

Request body:

{
  "ids": ["notif_01...", "notif_02..."],
  "markAllRead": false
}

Set markAllRead: true to mark all notifications for the user as read (ignores ids).

Response — 204 No Content


User endpoints

GET /api/v1/users/me/dashboard-layout

Get the authenticated user’s saved dashboard layout.

Required permission: Authenticated user (any role)

Response — 200 OK:

{
  "layout": [
    { "widgetId": "cluster-health", "x": 0, "y": 0, "w": 6, "h": 4 },
    { "widgetId": "recent-jobs",    "x": 6, "y": 0, "w": 6, "h": 4 }
  ],
  "updatedAt": "2026-05-20T12:00:00Z"
}

Response — 204 No Content: No layout has been saved yet (portal uses its default layout).


PATCH /api/v1/users/me/dashboard-layout

Save the authenticated user’s dashboard layout.

Required permission: Authenticated user (any role)

Request body: Same schema as the GET response layout array.

Response — 204 No Content


GET /api/v1/users/me/saved-filters

List saved filters for the authenticated user.

Required permission: Authenticated user (any role)

Response — 200 OK:

{
  "items": [
    {
      "id": "filter_01...",
      "name": "Degraded clusters",
      "context": "clusters",
      "filter": { "status": "Degraded" },
      "createdAt": "2026-05-10T09:00:00Z"
    }
  ]
}

POST /api/v1/users/me/saved-filters

Create a saved filter.

Required permission: Authenticated user (any role)

Request body:

{
  "name": "Degraded clusters",
  "context": "clusters",
  "filter": { "status": "Degraded" }
}

Response — 201 Created: Returns the created filter object with its id.


DELETE /api/v1/users/me/saved-filters/{id}

Delete a saved filter.

Required permission: Authenticated user (any role)

Response — 204 No Content


Platform endpoints

POST /api/v1/platform/audit

Write a custom audit log entry.

Required permission: platform:admin

Request body:

{
  "action": "manual.export",
  "resource": "clusters",
  "detail": "Operator exported cluster inventory to CSV."
}

Response — 204 No Content


POST /api/v1/platform/identity/providers

Add or update an identity provider.

Required permission: platform:admin

Request body:

{
  "type": "EntraID",
  "tenantId": "00000000-0000-0000-0000-000000000000",
  "clientId": "00000000-0000-0000-0000-000000000001",
  "clientSecret": "your-client-secret"
}

Supported type values: EntraID, LDAP, OIDC, Keycloak

Response — 201 Created:

{
  "id": "idp_01...",
  "type": "EntraID",
  "status": "Configured",
  "createdAt": "2026-05-27T08:00:00Z"
}

POST /api/v1/platform/identity/consent-callback

OAuth2 consent callback endpoint for identity provider authorization flows. This endpoint is called by the identity provider after the operator consents to the app registration. It is not called directly by automation.

Response — 302 Redirect: Redirects to the portal with the result of the consent flow.


Update endpoints

GET /api/v1/platform/updates/check

Check whether a newer version of CloudSmith is available.

Required permission: platform:admin

Response — 200 OK:

{
  "currentVersion": "0.5.3",
  "latestVersion": "0.6.0",
  "updateAvailable": true,
  "releaseNotesUrl": "https://github.com/cloudsmith-cloud/cloudsmith-installer/releases/tag/v0.6.0",
  "checkedAt": "2026-05-27T08:00:00Z"
}

Response when up to date:

{
  "currentVersion": "0.6.0",
  "latestVersion": "0.6.0",
  "updateAvailable": false,
  "checkedAt": "2026-05-27T08:00:00Z"
}

PUT /api/v1/platform/updates/apply

Apply an available update or roll back to a previous version.

Required permission: platform:admin

Request body:

{
  "targetVersion": "0.6.0",
  "action": "apply"
}

Set action to "apply" to update to targetVersion, or "rollback" to roll back to the previous installed version.

Response — 202 Accepted:

{
  "jobId": "job_01hz...",
  "action": "apply",
  "targetVersion": "0.6.0",
  "status": "InProgress"
}

Poll GET /api/v1/jobs/{jobId} for completion. The update pulls new container images and performs a rolling restart of the Docker Compose stack. No VM reboot is required for standard updates.

Note: On-premises updates require outbound access from the CloudSmith VM to ghcr.io. Bundled and Appliance mode sites that are fully air-gapped must use the bundled update path — see Bundled install — updating.


Error responses

All error responses use the following schema:

{
  "error": "error_code",
  "message": "Human-readable description.",
  "traceId": "00-abc123..."
}
HTTP status Meaning
400 Bad request — invalid input, missing required fields
401 Unauthenticated — missing or invalid Bearer token
403 Forbidden — authenticated but lacks the required permission
404 Not found — resource does not exist or is not accessible
409 Conflict — operation cannot proceed in the current state
422 Unprocessable entity — input is structurally valid but semantically invalid
429 Too many requests — rate limit exceeded
500 Internal server error — unexpected server-side failure

Include the traceId when reporting issues; it correlates the request to the API and OpenTelemetry traces.