Control plane API reference
For platform teams. This page lists the HTTP calls of the control plane: what each call does, which role it needs, what it takes and what it answers. The CLI and the Platform dashboard use these calls. You can use them with curl.
The calls in the examples run against the address in the variable JOOMIZ_SERVER_URL. The outputs show https://joomiz.example.com in place of the real address, and example IDs. The control plane sends each JSON answer on one line, except the schema. Some examples have line breaks, so that you can read them.
Conventions
- Paths are plural nouns in kebab-case, for example
/applications. A value in a path, such as an ID, is written{id}. - Query names are in snake_case, for example
page_size. - JSON fields are in camelCase, for example
goldenPath. Ajoomiz.yamlbody is YAML, and its keys are in kebab-case. - Bodies in are YAML for
/validate,/applications,/planand/apply. The content type isapplication/yaml,application/x-yamlortext/yaml. Any other content type gets415. The other calls take JSON. - Versioning. The header
X-API-Versionnames the version of the API. The only version is1. A request with no header uses version1. Any other value gets400. No path has a version in it. - Errors are problem details (RFC 9457) with the content type
application/problem+json. Errors lists them. - Paging.
/runsand/audit-eventstakecursorandpage_size. The page size is from 1 to 100, and the default is 50. When more items exist, the answer has anextCursor. Send it ascursorto get the next page./applicationstakespage_numberandpage_size. Its page size is from 1 to 100, and the default is 20. - Roles. Every call needs a token, except
GET /schema. A call that needs the roleoperatoris marked below. Every other call works with a roleowner:<team>for the applications of that team. An operator can use them for every application.
Call the API with curl
Read the token into a variable without showing it. Then give it to curl from the environment, so it is not on the command line:
read -rs JOOMIZ_TOKEN
export JOOMIZ_TOKEN
curl -s -w '\nHTTP %{http_code}\n' --variable '%JOOMIZ_TOKEN' --expand-header 'Authorization: Bearer {{JOOMIZ_TOKEN}}' "$JOOMIZ_SERVER_URL/runs?page_size=1"
This needs curl 8.3 or later. A call with no valid token gets 401 with the title Unauthorized.
The calls
| Call | What it does | Role |
|---|---|---|
GET /schema | Returns the JSON Schema of joomiz.yaml. | None. It needs no token. |
POST /validate | Checks a joomiz.yaml. It changes nothing. | Owner of the application, or operator |
POST /applications | Registers an application. | Owner of the application, or operator |
GET /applications | Lists the applications that you may see. | Owner or operator |
GET /applications/{id} | Returns one application. | Owner of the application, or operator |
POST /plan?environment=<name> | Plans a joomiz.yaml for one environment. | Owner of the application, or operator |
POST /apply?environment=<name> | Applies the plan of a joomiz.yaml to one environment. | Owner of the application, or operator |
GET /applications/{id}/environments/{environment}/operations/{operationId} | Returns the state of a slow operation of a provisioner. | Owner of the application, or operator |
GET /runs | Lists runs, newest first. | Owner or operator |
GET /runs/{id} | Returns one run with its resource types. | Owner or operator |
GET /runs/{id}/events | Returns the events of a run. | Owner or operator |
PATCH /runs/{id} | Releases the apply lock of a run. | Operator |
GET /audit-events | Lists audit events, newest first. | Owner or operator |
GET /audit-events/{id} | Returns one audit event. | Owner or operator |
POST /audit-erasures | Erases a user ID from the audit trail. | Operator |
POST /provisioners | Registers a provisioner. | Operator |
GET /provisioners | Lists the provisioners. | Operator |
GET /provisioners/{id} | Returns one provisioner. | Operator |
PUT /provisioners/{id} | Replaces a registration. | Operator |
DELETE /provisioners/{id} | Removes a registration. | Operator |
The body of a POST /validate, POST /plan and POST /apply names the application by its application.id. For plan, apply and validate, the application must be registered first. For an owner, a run or an audit event of another team is 404. An application of another team is 403 with the title Not your application.
Schema
GET /schema returns the schema with the content type application/schema+json. The type values and the spec shapes come from the catalog of your control plane, every time you ask:
curl -s "$JOOMIZ_SERVER_URL/schema"
{
"$schema" : "http://json-schema.org/draft-07/schema#",
"title" : "joomiz.yaml",
"description" : "The Joomiz application manifest. The `type` values each list accepts, and the shape each type's `spec` must have, are composed from the platform's live catalogs every time this schema is fetched, so a newly registered type is available as soon as it is registered.",
"type" : "object",
"additionalProperties" : false,
"required" : [ "application" ],
"properties" : {
"version" : {
...
The output is cut after the first lines.
Applications
POST /validate takes a joomiz.yaml and answers 200 with a report. The field valid is false when any check fails, and errors lists problems of the whole file:
curl -s -w '\nHTTP %{http_code}\n' --variable '%JOOMIZ_TOKEN' --expand-header 'Authorization: Bearer {{JOOMIZ_TOKEN}}' -X POST -H 'Content-Type: application/yaml' --data-binary @joomiz.yaml "$JOOMIZ_SERVER_URL/validate"
{"applicationId":"95791280-e2ce-4f92-af03-e04f36a84969","errors":[],"application":{"errors":[],"valid":true},"tooling":{"errors":[],"valid":true},"environments":[{"name":"test","errors":[],"targets":[{"id":"test-foundation","type":"cloud-foundry-space","errors":[],"valid":true}],"deployments":[{"id":"shop-test-deployment","type":"cloud-foundry-app","artifact":"shop-jar","target":"test-foundation","errors":[],"valid":true}],"managedResources":[],"consumedResources":[],"valid":true}],"valid":true}
HTTP 200
This is the answer for a file with a key that does not exist:
{"applicationId":"95791280-e2ce-4f92-af03-e04f36a84969","errors":["application: unknown key \"colour\""],"application":{"errors":[],"valid":true},"tooling":{"errors":[],"valid":true},"environments":[{"name":"test","errors":[],"targets":[{"id":"test-foundation","type":"cloud-foundry-space","errors":[],"valid":true}],"deployments":[{"id":"shop-test-deployment","type":"cloud-foundry-app","artifact":"shop-jar","target":"test-foundation","errors":[],"valid":true}],"managedResources":[],"consumedResources":[],"valid":true}],"valid":false}
HTTP 200
POST /applications stores an application. The answer is 201, with the header Location: /applications/{id}. The body names the application:
{
"id": "95791280-e2ce-4f92-af03-e04f36a84969",
"name": "hello-service",
"description": "A small shop service for the quick start.",
"repository": "https://github.com/example/hello-service",
"goldenPath": "java-web-application",
"owner": {
"id": "finance-team",
"name": "Finance Team"
},
"tooling": [],
"artifacts": [
{
"id": "shop-jar",
"type": "jar",
"spec": {
"path": "target/shop.jar"
}
}
]
}
HTTP 201
A second registration of the same application ID answers 409 with the title Application already registered. A file whose application.owner.id is not the owner of your role answers 400 with the title Invalid owner.
GET /applications takes owner_id, page_number and page_size. It answers a page. An owner who asks for the owner_id of another team gets an empty page:
curl -s -w '\nHTTP %{http_code}\n' --variable '%JOOMIZ_TOKEN' --expand-header 'Authorization: Bearer {{JOOMIZ_TOKEN}}' "$JOOMIZ_SERVER_URL/applications?page_size=2"
{
"items": [
{
"id": "4569ce39-2dcb-4431-ae13-083b06393362",
"name": "orders-database",
"owner": {
"id": "finance-team",
"name": "Finance Team"
}
},
{
"id": "95791280-e2ce-4f92-af03-e04f36a84969",
"name": "hello-service",
"owner": {
"id": "finance-team",
"name": "Finance Team"
}
}
],
"pageNumber": 1,
"pageSize": 2,
"totalItems": 4,
"totalPages": 2
}
HTTP 200
GET /applications/{id} answers the same fields as the registration.
Plan and apply
POST /plan and POST /apply take the joomiz.yaml as the body and the name of the environment in the query environment. Without it the answer is 400. A file that cannot be planned gets 200 with a report like the one of /validate, and valid is false.
A file that can be planned gets a stream of JSON lines, one JSON object on each line, with the content type application/x-ndjson. The header X-Joomiz-Run-Id holds the ID of the run. The stream has one group line for each kind and type of resource, and a last summary line.
{
"kind": "deployment",
"resourceType": "cloud-foundry-app",
"outcome": "succeeded",
"description": "Planned 1 change for deployment/cloud-foundry-app.",
"results": [
{
"id": "shop-test-deployment",
"action": "created",
"outcome": "succeeded",
"description": "Would create 'shop-test-deployment' (deployment/cloud-foundry-app) in environment 'test'. Nothing was changed.",
"outputs": null,
"unresolvedFields": []
}
],
"operationId": null,
"type": "group"
}
The summary line of a plan holds the revision, the plan, the status and the ID of the run:
{"revisionId":"e3a03634-d1f6-4ffe-af87-af9020921e4f","revisionPlanId":"e1d8689a-fb66-46ac-9d8a-e4563a5ad6cd","status":"succeeded","type":"summary","runId":"fa656ad8-3a76-4c99-8b34-a8d72f4ca697","driftComplete":true}
HTTP 200
The summary line of an apply has the plan, the apply and the status:
{"revisionPlanId":"e1d8689a-fb66-46ac-9d8a-e4563a5ad6cd","revisionPlanApplicationId":"36eafecc-1eaf-44dc-8944-631557bedd1e","status":"succeeded","type":"summary","runId":"e539f576-34da-43df-9fdc-05ed59464b23"}
HTTP 200
A group line of a slow operation holds the field operationId. An apply with no plan answers 409. The control plane also refuses a plan that is too old:
{"detail":"this file has never been planned; run plan for this environment before applying it","status":409,"title":"No appliable plan"}
HTTP 409
{"detail":"the applied state of this application and environment changed after this plan was made; plan again, review the result and apply it","status":409,"title":"Stale plan","type":"tag:joomiz.com,2026:problems/stale-plan"}
HTTP 409
The 409 of a second apply holds the field holder. Release a stuck apply lock shows it.
Runs
GET /runs takes the filters application_id, environment, kind (plan or apply), status, started_after and started_before, and the paging fields. The times are ISO 8601. The answer is the first item here, and the other items have the same fields:
{
"items": [
{
"id": "e539f576-34da-43df-9fdc-05ed59464b23",
"kind": "apply",
"applicationId": "95791280-e2ce-4f92-af03-e04f36a84969",
"applicationName": "hello-service",
"environment": "test",
"caller": "owner:finance-team",
"status": "succeeded",
"refusalReason": null,
"revisionId": "e3a03634-d1f6-4ffe-af87-af9020921e4f",
"revisionPlanId": "e1d8689a-fb66-46ac-9d8a-e4563a5ad6cd",
"revisionPlanApplicationId": "36eafecc-1eaf-44dc-8944-631557bedd1e",
"traceId": null,
"startedAt": "2026-10-28T19:25:42.769Z",
"finishedAt": "2026-10-28T19:25:42.785Z",
"lastEventAt": "2026-10-28T19:25:42.785Z",
"lastSequence": 8,
"incomplete": false
}
],
"nextCursor": null
}
HTTP 200
The field caller holds the role that started the run. This documentation says acted as. The field traceId is null when the control plane sends no traces. The status is one of running, accepted, succeeded, failed, partially_applied, refused and abandoned. Run results, refusal reasons and exit codes explains them.
GET /runs/{id} answers the run, its resource types in groups, and, for an abandoned run, the field abandonedBy with operator or system:
{
"run": {
"id": "e539f576-34da-43df-9fdc-05ed59464b23",
"kind": "apply",
"applicationId": "95791280-e2ce-4f92-af03-e04f36a84969",
"applicationName": "hello-service",
"environment": "test",
"caller": "owner:finance-team",
"status": "succeeded",
"refusalReason": null,
"revisionId": "e3a03634-d1f6-4ffe-af87-af9020921e4f",
"revisionPlanId": "e1d8689a-fb66-46ac-9d8a-e4563a5ad6cd",
"revisionPlanApplicationId": "36eafecc-1eaf-44dc-8944-631557bedd1e",
"traceId": null,
"startedAt": "2026-10-28T19:25:42.769Z",
"finishedAt": "2026-10-28T19:25:42.785Z",
"lastEventAt": "2026-10-28T19:25:42.785Z",
"lastSequence": 8,
"incomplete": false
},
"groups": [
{
"kind": "target",
"type": "cloud-foundry-space",
"state": "succeeded",
"durationMillis": 6,
"resolvedAfterMillis": null,
"operationId": null,
"counts": {
"created": 1,
"updated": 0,
"removed": 0
}
},
"..."
]
}
HTTP 200
GET /runs/{id}/events takes after_sequence (0 or more) and page_size (1 to 100, default 50). It answers the events with a sequence number above the one that you give. The event run_abandoned also has the field actor, with operator or system. The CLI and the dashboard use this call to follow a run:
{
"runId": "e539f576-34da-43df-9fdc-05ed59464b23",
"status": "succeeded",
"events": [
{
"sequence": 7,
"at": "2026-10-28T19:25:42.784Z",
"type": "group_finished",
"groupKind": "deployment",
"groupType": "cloud-foundry-app",
"outcome": "succeeded",
"status": null,
"durationMillis": 5,
"operationId": null,
"counts": {
"created": 1,
"updated": 0,
"removed": 0
}
},
{
"sequence": 8,
"at": "2026-10-28T19:25:42.785Z",
"type": "run_finished",
"groupKind": null,
"groupType": null,
"outcome": null,
"status": "succeeded",
"durationMillis": null,
"operationId": null,
"counts": null
}
]
}
HTTP 200
PATCH /runs/{id} takes the body {"status":"abandoned"} and nothing else. Only an operator may use it, and only for a run with the status running or accepted. It does not stop work that is already running. The answer is the run:
{
"id": "63d8876f-f99a-41fc-9947-513aa0f8b039",
"kind": "apply",
"applicationId": "4569ce39-2dcb-4431-ae13-083b06393362",
"applicationName": "orders-database",
"environment": "test",
"caller": "owner:finance-team",
"status": "abandoned",
"refusalReason": null,
"revisionId": "0dd26ab7-ede7-44de-b84a-825e3a31ebd9",
"revisionPlanId": "78394874-5d29-41a4-abc1-4d1c43ce8bbf",
"revisionPlanApplicationId": "4a36eb2c-2ec5-4cd2-8cfe-283a32bc4b19",
"traceId": null,
"startedAt": "2026-10-28T19:27:09.971Z",
"finishedAt": "2026-10-28T19:27:10.684Z",
"lastEventAt": "2026-10-28T19:27:10.684Z",
"lastSequence": 6,
"incomplete": false
}
HTTP 200
Audit
GET /audit-events takes application_id, run_id, action, outcome, from, to and the paging fields. The filter actor_subject is for operators. GET /audit-events/{id} answers one event. Read and manage the audit trail explains the fields:
{
"items": [
{
"id": "04dd5179-da81-4457-ad99-608baa6f59fe",
"occurredAt": "2026-10-28T19:25:42.770Z",
"actorKind": "person",
"actorSubject": "e2b84d17-6c90-4a35-9f1e-0d3a58b7c641",
"authority": "owner:finance-team",
"action": "apply.requested",
"outcome": "performed",
"targetType": "application",
"targetId": "95791280-e2ce-4f92-af03-e04f36a84969",
"applicationId": "95791280-e2ce-4f92-af03-e04f36a84969",
"environment": "test",
"runId": "e539f576-34da-43df-9fdc-05ed59464b23"
}
],
"nextCursor": "MjAyNi0xMC0yOFQxOToyNTo0Mi43NzBafDA0ZGQ1MTc5LWRhODEtNDQ1Ny1hZDk5LTYwOGJhYTZmNTlmZQ"
}
HTTP 200
POST /audit-erasures takes the body {"subject": "<user ID>"} and nothing else. It answers 201 with the ID of the erasure and the number of events that it changed. An operator cannot erase their own user ID.
Provisioners
The calls on /provisioners are for operators. Register a provisioner shows each call, the fields, and the errors.
Slow operations
This call answers the state of an operation that a provisioner accepted and still works on:
GET /applications/{id}/environments/{environment}/operations/{operationId}
The fields are operationId, applicationId, environment, kind, resourceType, dispatch, status, description, resources, acceptedAt, expiresAt and resolvedAt. An ID that is not for this application and environment answers 404 with the title Operation not found:
{"detail":"no operation '92806345-aa06-4574-bbfe-01ed91759045' for this application and environment","status":404,"title":"Operation not found"}
HTTP 404
Errors
A problem detail has the fields status, title and detail. A few add a field, for example holder or violations. The field type is missing, which means about:blank, except for the three types in the next table. The text of detail can change. Match on the status and the type, and not on the text.
type | Status | When |
|---|---|---|
tag:joomiz.com,2026:problems/apply-in-progress | 409 | Another apply of the same application and environment holds the lock. The field holder names its run. |
tag:joomiz.com,2026:problems/stale-plan | 409 | The environment changed after the plan. Plan again. |
tag:joomiz.com,2026:problems/audit-unavailable | 503 | The control plane could not write the audit event, so it did not do the action. Try again later. |
The statuses and titles are:
| Status | Titles |
|---|---|
400 | Invalid application, Invalid owner, Invalid tooling, Invalid artifacts, Invalid golden path, Invalid joomiz.yaml, Unsupported joomiz.yaml version, Ambiguous owner role, Invalid page request, Invalid run query, Invalid run update, Invalid audit query, Invalid audit erasure, Invalid provisioner, Invalid operation id, Malformed request body, Bad Request |
401 | Unauthorized |
403 | Operator role required, Not your application, No Joomiz role, Audit erasure refused |
404 | Application not found, Run not found, Audit event not found, Provisioner not found, Operation not found, Not Found |
405 | Method Not Allowed |
409 | Application already registered, Provisioned type already claimed, Run cannot be abandoned, No appliable plan, Apply already in progress, Stale plan |
413 | Request body too large |
415 | Unsupported Media Type |
422 | No provisioner registered, Circular resource reference, Reserved deployment spec field |
500 | Internal server error |
503 | Run could not be recorded, Audit trail unavailable, Busy |
These are real answers:
{"detail":"You are not authorized to act on this application. It belongs to a different owner.","status":403,"title":"Not your application"}
HTTP 403
{"detail":"run query is invalid: status must be one of running, accepted, succeeded, failed, partially_applied, refused, abandoned","status":400,"title":"Invalid run query"}
HTTP 400
{"detail":"This operation requires the operator role. Releasing an apply lock is a platform operation, not an application-owner one.","status":403,"title":"Operator role required"}
HTTP 403
{"detail":"This path supports only these methods: GET.","status":405,"title":"Method Not Allowed"}
HTTP 405
The bad version of the API:
{"detail":"The request is not valid.","status":400,"title":"Bad Request"}
HTTP 400
See also
- CLI reference lists the commands that send these calls for you.
- Run results, refusal reasons and exit codes explains the words in the answers.
- Settings reference lists the settings of the control plane.