Skip to content
Docs BETA

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

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