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.