{
  "openapi": "3.1.0",
  "x-hetscale-status": "draft",
  "info": {
    "title": "Hetscale API",
    "version": "0.12.0-draft",
    "description": "Public API of the Hetscale control plane: the groups and their projects, the node reports, the release pointer, the inventory API, the deploy lock, the in-place deploy report, the rollout and the Prometheus endpoint, plus the signed deliveries Hetscale sends to the customer's endpoints: the webhook adapter's deployment events and, on Pro, the integrations' timeline events, alerts and tests. Operations marked implemented are served under `/v1` on `api.hetscale.com`; the others are planned and may still change. Eight operations are stable and marked `x-hetscale-stability: stable`: the ones a deploy pipeline calls to hold and release a group's deploy lock, create a release, list a group's nodes, report an in-place deploy, start a rollout, and report that the deploy to a new node succeeded or failed. Within `/v1`, what a pipeline sends them and reads from their answers is not removed, renamed or narrowed: the method, path and ability of each, the request fields and their bounds, the success statuses, the answer fields a pipeline uses (the lock's end; the release's id, version, ref and build id and the group's generation; each node's id, class, phase, IPv4 address, private address and release; the rollout's id and its count of older servers; the reported node's phase), the node phases, `Retry-After`, and the error body and its codes. Everything else in this document is a draft and may still change before it leaves draft."
  },
  "servers": [
    {
      "url": "https://api.hetscale.com/v1"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "tags": [
    {
      "name": "groups",
      "description": "Scaling groups: their documents and settings, the start of Observe, the Archive, the current node inventory, the deploy lock and the rollout of older servers."
    },
    {
      "name": "projects",
      "description": "The organisation's Hetzner projects, connected on the panel."
    },
    {
      "name": "nodes",
      "description": "Optional callbacks a deploy pipeline sends about a single node."
    },
    {
      "name": "releases",
      "description": "The release pointer every scale-out and healing action boots from."
    },
    {
      "name": "metrics",
      "description": "Every group's state in the Prometheus text format, for a Prometheus server to scrape. Part of the Pro and Team plans."
    }
  ],
  "paths": {
    "/projects": {
      "get": {
        "operationId": "listProjects",
        "x-hetscale-status": "implemented",
        "tags": ["projects"],
        "summary": "List the organisation's Hetzner projects",
        "description": "With a token that has the `groups:read` ability. Every Hetzner project of the organisation, oldest first, with its id, name, status and token level; the token and its fingerprint never appear. A project is connected on the panel only: no Hetzner token travels through the API.",
        "responses": {
          "200": {
            "description": "The projects.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Project"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/groups": {
      "get": {
        "operationId": "listGroups",
        "x-hetscale-status": "implemented",
        "tags": ["groups"],
        "summary": "List the organisation's groups",
        "description": "With a token that has the `groups:read` ability. Every group outside the Archive, oldest first; `archived=true` lists the Archive instead. The list comes in a `data` wrapper, so paging can come later as an addition.",
        "parameters": [
          {
            "name": "archived",
            "in": "query",
            "required": false,
            "description": "`true` lists the groups in the Archive.",
            "schema": {
              "type": "string",
              "enum": ["true", "false"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The groups.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["data"],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/GroupSummary"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/GroupRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "operationId": "createGroup",
        "x-hetscale-status": "implemented",
        "tags": ["groups"],
        "summary": "Start observing a load balancer",
        "description": "With a token that has the `groups:write` ability, as the panel's Observe setup does. The case answers are assessed against the project's latest read and kept as its onboarding case; then Hetscale reads the load balancer and the project's servers inside the request and creates the group in `observe`, with the seed and every other server behind the balancer adopted. A load balancer has one trial, ever. The day-0 report follows in the background; the group's document is its status. Going Live stays on the panel.",
        "parameters": [
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateGroup"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The new group in `observe`.",
            "headers": {
              "ETag": {
                "description": "A digest of the `settings` block: it changes when a setting does, never with the tick. Send it back in `If-Match`.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GroupDocument"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "$ref": "#/components/responses/GroupConflict"
          },
          "422": {
            "$ref": "#/components/responses/GroupRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "503": {
            "$ref": "#/components/responses/ProviderUnavailable"
          }
        }
      }
    },
    "/groups/{groupId}": {
      "get": {
        "operationId": "getGroup",
        "x-hetscale-status": "implemented",
        "tags": ["groups"],
        "summary": "Read a group",
        "description": "With a token that has the `groups:read` ability, of a member who sees the group, archived or not. The document is the group's JSON export: its `settings` block goes back unchanged as a `PATCH` body, and applying it to the same group changes nothing.",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/GroupDocument"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "patch": {
        "operationId": "updateGroup",
        "x-hetscale-status": "implemented",
        "tags": ["groups"],
        "summary": "Change a group's settings",
        "description": "With a token that has the `groups:write` ability, of a member who may configure the group, which needs the organisation open for writes. Changes what the panel's Settings page changes, the SSH keys of new servers and the decision mode, in one transaction: when one part is refused, nothing is written. A field left out keeps its value. Live and paused groups only (`not_live`). The timeline records the change with the actor `api_token` and the token's name. With `If-Match` the change is written only while the settings still have that `ETag`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          },
          {
            "name": "If-Match",
            "in": "header",
            "required": false,
            "description": "The `ETag` of a read of the group; a setting changed since is answered 412 `precondition_failed`. Without it the last write wins, as on the panel.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GroupSettings"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/GroupDocument"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/GroupConflict"
          },
          "412": {
            "$ref": "#/components/responses/PreconditionFailed"
          },
          "422": {
            "$ref": "#/components/responses/GroupRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "operationId": "archiveGroup",
        "x-hetscale-status": "implemented",
        "tags": ["groups"],
        "summary": "Archive a group",
        "description": "With a token that has the `groups:write` ability, of a member who sees the group. Puts it in the Archive; nothing is ever deleted, and its reports and timeline stay. Refused while a server Hetscale created is alive in a project it can still reach (`servers_running`), during a Rehearsal (`rehearsing`) and for a group in the Archive already (`already_archived`).",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/GroupDocument"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/GroupConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/groups/{groupId}/restore": {
      "post": {
        "operationId": "restoreGroup",
        "x-hetscale-status": "implemented",
        "tags": ["groups"],
        "summary": "Bring a group back from the Archive",
        "description": "With a token that has the `groups:write` ability, of a member who sees the group. An observing or paused group comes back as it was, a Live one paused, since its servers may have changed while nothing read them. A group outside the Archive is answered `not_archived`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "$ref": "#/components/responses/GroupDocument"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/GroupConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/groups/{groupId}/nodes": {
      "get": {
        "operationId": "listGroupNodes",
        "x-hetscale-status": "implemented",
        "x-hetscale-stability": "stable",
        "tags": ["groups"],
        "summary": "List the current nodes of a group",
        "description": "Inventory API, with a token that has the `nodes:read` ability, of a member who sees the group. Returns the group and its current nodes with phase, IP addresses, class, release, build id and `older`, so pipelines and tools such as Kamal or Ansible build their host lists from it instead of parsing events. A node is older when it runs another release than the group's current one by ref and build id, or none, or a template version that differs in what changes a running server, or when it runs outside a placement group while the group spreads its servers across hosts; a rollout replaces it, except while the load balancer no longer lists it as a target: a server was started in its place, and it leaves first on a scale-in. `phase=active` keeps the nodes that serve.",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          },
          {
            "name": "phase",
            "in": "query",
            "required": false,
            "description": "`active` keeps the nodes in the `ACTIVE` phase, the ones that serve.",
            "schema": {
              "type": "string",
              "enum": ["active"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The group and its current nodes.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["group", "nodes"],
                  "properties": {
                    "group": {
                      "$ref": "#/components/schemas/Group"
                    },
                    "nodes": {
                      "type": "array",
                      "description": "Every node the group tracks and has not terminated, in any phase; with `phase=active` only the serving ones.",
                      "items": {
                        "$ref": "#/components/schemas/Node"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/groups/{groupId}/lock": {
      "post": {
        "operationId": "lockGroup",
        "x-hetscale-status": "implemented",
        "x-hetscale-stability": "stable",
        "tags": ["groups"],
        "summary": "Hold a deploy lock on a group",
        "description": "Deploy lock, with a token that has the `groups:lock` ability, of a member who may change the group. While it holds, Hetscale makes no scaling decision and creates no server for a scale-out, a rollout, the fallback way back or a capacity window, so a scale-out cannot race a fleet-wide deploy. A healing replacement is never held, and a server a decision before the lock released still leaves. The lock ends at `locked_until` on its own, so a crashed pipeline cannot freeze a group; a second call sets a new end. The group must be Live or paused.",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ttl", "reason"],
                "properties": {
                  "ttl": {
                    "type": "integer",
                    "minimum": 60,
                    "maximum": 7200,
                    "description": "Seconds after which the lock ends on its own, from 60 to 7,200."
                  },
                  "reason": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "description": "Why the lock is held; shown on the group's timeline and page."
                  }
                }
              },
              "example": {
                "ttl": 600,
                "reason": "deploy build 4711 via GitHub Actions"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The lock is held until the returned time.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["locked_until", "reason"],
                  "properties": {
                    "locked_until": {
                      "type": "string",
                      "format": "date-time",
                      "description": "When the lock expires and scaling actions resume."
                    },
                    "reason": {
                      "type": "string",
                      "description": "The reason given when the lock was taken."
                    }
                  }
                },
                "example": {
                  "locked_until": "2026-09-15T13:12:11Z",
                  "reason": "deploy build 4711 via GitHub Actions"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/LockConflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "operationId": "unlockGroup",
        "x-hetscale-status": "implemented",
        "x-hetscale-stability": "stable",
        "tags": ["groups"],
        "summary": "Release a group's deploy lock",
        "description": "With a token that has the `groups:lock` ability, of a member who may change the group. Releases the lock at once, so scaling decisions and rollouts go on; the answer is the same when no lock was held.",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "No lock is held now.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["locked_until", "reason"],
                  "properties": {
                    "locked_until": {
                      "type": "null",
                      "description": "Always null: the group holds no lock."
                    },
                    "reason": {
                      "type": "null",
                      "description": "Always null."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/groups/{groupId}/releases": {
      "post": {
        "operationId": "createGroupRelease",
        "x-hetscale-status": "implemented",
        "x-hetscale-stability": "stable",
        "tags": ["releases"],
        "summary": "Create a release and move the group's current pointer to it",
        "description": "With a token that has the `releases:write` ability, of a member who may change the group. Records a release and makes it the group's `current` release. Every scale-out and every healing action boots or deploys from `current`; `:latest` is never used. The group's generation is incremented, which discards scaling decisions made against the previous release, and `group.release_changed` goes on the group's timeline; servers already running keep the release they booted from. For a snapshot group, `ref` is the numeric id of a snapshot in the group's Hetzner project, and Hetscale reads it back before it takes it: a snapshot, available, of the group's CPU architecture and no larger than the disk of the group's server type. For a webhook group, `ref` names the build its pipeline deploys — an image digest, a tag or a commit, 1 to 200 visible characters — never a mutable `:latest`; the group's endpoint receives `group.release_changed`, and every later `node.created` names the new release. The group must be Live or paused, have release v1 and no snapshot of its seed in progress. Naming the current ref and build again changes nothing and answers 200 with the current release; naming an earlier ref again is a new release.",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["ref"],
                "properties": {
                  "ref": {
                    "type": "string",
                    "maxLength": 200,
                    "description": "The artefact to boot or deploy from: for a snapshot group the numeric id of a snapshot in the group's Hetzner project, for a webhook group the build its pipeline deploys, such as an image digest."
                  },
                  "build_id": {
                    "type": ["string", "null"],
                    "maxLength": 120,
                    "description": "The customer's own build identifier for this release; nodes report it so stale nodes can be found."
                  }
                }
              },
              "example": {
                "ref": "231456789",
                "build_id": "4712"
              }
            }
          }
        },
        "responses": {
          "200": {
            "$ref": "#/components/responses/ReleaseCurrent"
          },
          "201": {
            "$ref": "#/components/responses/ReleaseCreated"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/ReleaseConflict"
          },
          "422": {
            "$ref": "#/components/responses/ReleaseRefused"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/ProviderError"
          },
          "503": {
            "$ref": "#/components/responses/ProviderUnavailable"
          }
        }
      }
    },
    "/nodes/{nodeId}/ready": {
      "post": {
        "operationId": "markNodeReady",
        "x-hetscale-status": "implemented",
        "x-hetscale-stability": "stable",
        "tags": ["nodes"],
        "summary": "Report that a node has been deployed",
        "description": "Required for a node of a webhook group, with a token that has the `nodes:report` ability. Tells Hetscale the deploy pipeline has finished with the node: Hetscale then adds it to the load balancer, and traffic opens only when the balancer's per-target health check passes, whatever this report says. A node without the report before `deadline_at` is destroyed. The report is bound to the node's release and generation, as `node.created` sent them, so a late report of an older rollout changes nothing: another release or generation, or a node that no longer waits for its deploy, is answered 409 with the node as it stands. An `ACTIVE` node takes the report; a repeated report is answered like the first.",
        "parameters": [
          {
            "$ref": "#/components/parameters/nodeId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "responses": {
          "202": {
            "$ref": "#/components/responses/NodeReportAccepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/NodeReportConflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        },
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NodeReport"
              },
              "example": {
                "release": "rel_01j8x4v9k2n7q3r5s6t7v8w9xd",
                "generation": 42
              }
            }
          }
        }
      }
    },
    "/nodes/{nodeId}/failed": {
      "post": {
        "operationId": "markNodeFailed",
        "x-hetscale-status": "implemented",
        "x-hetscale-stability": "stable",
        "tags": ["nodes"],
        "summary": "Report that the deploy to a node failed",
        "description": "Optional report, with a token that has the `nodes:report` ability. Moves the node to `DEPLOY_FAILED` at once instead of waiting for the readiness deadline and records the reason in the event timeline. The failure counts toward the group's bad-release brake: three consecutive failures pause the group. The report is bound to the node's release and generation like the ready report and is taken only while the node waits for its deploy (`BOOTING`, `DEPLOYING` or `HEALTH_CHECKING`); a repeated report is answered like the first.",
        "parameters": [
          {
            "$ref": "#/components/parameters/nodeId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/NodeReport"
                  },
                  {
                    "type": "object",
                    "required": ["reason"],
                    "properties": {
                      "reason": {
                        "type": "string",
                        "maxLength": 500,
                        "description": "Why the deploy failed, at most 500 characters; shown in the group's event timeline."
                      }
                    }
                  }
                ]
              },
              "example": {
                "release": "rel_01j8x4v9k2n7q3r5s6t7v8w9xd",
                "generation": 42,
                "reason": "docker compose up exited with status 1"
              }
            }
          }
        },
        "responses": {
          "202": {
            "$ref": "#/components/responses/NodeReportAccepted"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/NodeReportConflict"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/groups/{groupId}/rollout": {
      "post": {
        "operationId": "startRollout",
        "x-hetscale-status": "implemented",
        "x-hetscale-stability": "stable",
        "tags": ["groups"],
        "summary": "Replace a group's older servers",
        "description": "With a token that has the `rollouts:write` ability, of a member who may change the group. Starts replacing the group's older servers one at a time, surge then remove: a server from the current release and template starts, and once the load balancer's health check passes, an older server leaves through the drain window. A replacement that fails stops the rollout. Under a deploy lock it waits. The group must be Live.",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "responses": {
          "201": {
            "description": "The rollout started.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["rollout"],
                  "properties": {
                    "rollout": {
                      "type": "object",
                      "required": ["id", "started_at", "release", "older"],
                      "properties": {
                        "id": {
                          "type": "string",
                          "description": "Rollout identifier, a ULID prefixed with `rol_`."
                        },
                        "started_at": {
                          "type": "string",
                          "format": "date-time",
                          "description": "When the rollout started."
                        },
                        "release": {
                          "type": ["string", "null"],
                          "description": "The release the replacements boot from, prefixed with `rel_`."
                        },
                        "older": {
                          "type": "integer",
                          "description": "How many servers were older when it started, leaving out a server the load balancer no longer lists as a target."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/RolloutConflict"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "operationId": "stopRollout",
        "x-hetscale-status": "implemented",
        "tags": ["groups"],
        "summary": "Stop replacing a group's older servers",
        "description": "With a token that has the `rollouts:write` ability, of a member who may change the group. Stops the running rollout; a replacement already under way finishes.",
        "parameters": [
          {
            "$ref": "#/components/parameters/groupId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "responses": {
          "200": {
            "description": "Whether a running rollout was stopped.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["stopped"],
                  "properties": {
                    "stopped": {
                      "type": "boolean",
                      "description": "False when no rollout ran."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/nodes/{nodeId}/deployed": {
      "post": {
        "operationId": "reportNodeDeployed",
        "x-hetscale-status": "implemented",
        "x-hetscale-stability": "stable",
        "tags": ["nodes"],
        "summary": "Report an in-place deploy on a node",
        "description": "With a token that has the `nodes:report` ability, of a member who sees the node's group. A webhook group's pipeline says it deployed a release of the group onto the running node in place: the node runs that release now, so a rollout no longer counts it older, and `node.deployed` goes on the group's timeline. The release the node already runs changes nothing.",
        "parameters": [
          {
            "$ref": "#/components/parameters/nodeId"
          },
          {
            "$ref": "#/components/parameters/idempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["release"],
                "properties": {
                  "release": {
                    "type": "string",
                    "description": "The release deployed, prefixed with `rel_`."
                  }
                }
              },
              "example": {
                "release": "rel_01j8x4v9k2n7q3r5s6t7v8w9xd"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The node and the release it runs now.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["node"],
                  "properties": {
                    "node": {
                      "type": "object",
                      "required": ["id", "phase", "generation", "release", "build_id"],
                      "properties": {
                        "id": {
                          "type": "string",
                          "description": "Node identifier, a ULID prefixed with `node_`."
                        },
                        "phase": {
                          "type": "string",
                          "description": "Current phase in the node lifecycle, as in `Node`."
                        },
                        "generation": {
                          "type": "integer",
                          "description": "The group generation the node was created for."
                        },
                        "release": {
                          "type": ["string", "null"],
                          "description": "The release the node runs, prefixed with `rel_`."
                        },
                        "build_id": {
                          "type": ["string", "null"],
                          "description": "The build id of that release, or null."
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/NodeDeployedConflict"
          },
          "422": {
            "description": "The body is not valid (`validation_failed`), or the release is not one of the node's group (`release_unknown`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/metrics": {
      "get": {
        "operationId": "getMetrics",
        "x-hetscale-status": "implemented",
        "tags": ["metrics"],
        "summary": "Read every group's gauges in the Prometheus text format",
        "description": "For a Prometheus server, with a token that has the `metrics:read` ability. The metrics come with the Pro and Team plans, or with a grant from Hetscale; otherwise the answer is 403 `plan_required`. Every group outside the Archive has its own series, labelled `group_id` (`grp_<ulid>`) and `group` (its name). Every metric is a gauge: `hetscale_group_desired_nodes`, `hetscale_group_min_nodes`, `hetscale_group_max_nodes`, `hetscale_group_active_nodes` (serving and counted as capacity), `hetscale_group_in_flight_nodes` (being created, booted, deployed or health-checked), `hetscale_group_shortfall_nodes` (desired less active and in flight, never below zero), `hetscale_group_last_time_to_capacity_seconds` and `hetscale_group_last_routed_timestamp_seconds` for the newest server routed (absent until one is), `hetscale_group_state` with the label `state` (1 for the group's state among `observe`, `rehearsing`, `live` and `paused`) and, for a group past Observe, `hetscale_group_capacity_limit` with the label `limit` (1 for each of `at_max`, `cost_guard`, `node_cap`, `euro_ceiling`, `plan_capacity` and `capacity_deficit` that keeps the group from adding a server now). The answer comes from Hetscale's own data, never from Hetzner, and is kept for 60 seconds per organisation: a scrape interval of 60 seconds is enough.",
        "responses": {
          "200": {
            "description": "The gauges in the Prometheus text format, version 0.0.4, served as `text/plain; version=0.0.4; charset=utf-8`.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                },
                "example": "# HELP hetscale_group_desired_nodes Servers the group should run now.\n# TYPE hetscale_group_desired_nodes gauge\nhetscale_group_desired_nodes{group_id=\"grp_01j8x4m2k7q9r3t5v6w8y0z1ab\",group=\"api-prod\"} 3\n# HELP hetscale_group_active_nodes Servers that serve and count as capacity.\n# TYPE hetscale_group_active_nodes gauge\nhetscale_group_active_nodes{group_id=\"grp_01j8x4m2k7q9r3t5v6w8y0z1ab\",group=\"api-prod\"} 2\n# HELP hetscale_group_capacity_limit Each limit that keeps the group from adding a server now: 1 while it holds.\n# TYPE hetscale_group_capacity_limit gauge\nhetscale_group_capacity_limit{group_id=\"grp_01j8x4m2k7q9r3t5v6w8y0z1ab\",group=\"api-prod\",limit=\"cost_guard\"} 1\n"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "The token lacks the `metrics:read` ability (`forbidden`), or the organisation's plan does not include the metrics (`plan_required`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    }
  },
  "webhooks": {
    "timeline": {
      "post": {
        "operationId": "integrationTimelineEvent",
        "x-hetscale-status": "implemented",
        "summary": "A timeline event of one of the organisation's groups",
        "description": "Sent to an integration webhook that takes every event (Pro, the Integrations page): one delivery per event of the organisation's groups' timelines, the moment the write it describes commits. `event` and the `X-Hetscale-Event` header carry the event's name, such as `node.routed`, `decision.scale_out` or `group.settings_changed`; the timeline's own figures are not sent. Deliveries are not ordered: order them by `occurred_at`, and deduplicate them by `delivery_id`.",
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookSignature"
          },
          {
            "$ref": "#/components/parameters/webhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/webhookDelivery"
          },
          {
            "$ref": "#/components/parameters/webhookEvent"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "event",
                  "delivery_id",
                  "sent_at",
                  "occurred_at",
                  "organization",
                  "group",
                  "node",
                  "actor",
                  "text",
                  "url"
                ],
                "properties": {
                  "event": {
                    "type": "string",
                    "description": "The timeline event's name.",
                    "examples": [
                      "node.routed"
                    ]
                  },
                  "delivery_id": {
                    "type": "string",
                    "description": "Identifier of this delivery, a ULID; the same value is sent in the delivery header. A retry repeats it."
                  },
                  "sent_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "When the delivery was queued; every retry repeats it."
                  },
                  "occurred_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "When the event happened."
                  },
                  "organization": {
                    "$ref": "#/components/schemas/DeliveryOrganization"
                  },
                  "group": {
                    "$ref": "#/components/schemas/DeliveryGroup"
                  },
                  "node": {
                    "oneOf": [
                      {
                        "$ref": "#/components/schemas/DeliveryNode"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "actor": {
                    "type": "string",
                    "description": "Who acted: `system`, `policy`, `user`, `api_token` and the like.",
                    "examples": [
                      "system"
                    ]
                  },
                  "text": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The reason the timeline shows, in English, or null."
                  },
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "description": "The group's timeline on the panel."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Any 2xx acknowledges the delivery. Any other answer, a redirect or no answer within 10 seconds is retried: eight attempts over about 38 minutes, and an endpoint that fails for 30 minutes without a success is suspended and probed once an hour."
          }
        }
      }
    },
    "alert": {
      "post": {
        "operationId": "integrationAlert",
        "x-hetscale-status": "implemented",
        "summary": "An alert of the organisation",
        "description": "Sent to an integration webhook that takes alerts (Pro, the Integrations page): every alert the organisation's members are mailed, one delivery each, the moment it is raised, never batched. `event` is `alert`; `alert.kind` names it and `alert.level` is `critical`, `warning` or `notice`. Use it for an on-call tool, a bot or a status page.",
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookSignature"
          },
          {
            "$ref": "#/components/parameters/webhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/webhookDelivery"
          },
          {
            "$ref": "#/components/parameters/webhookEvent"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "event",
                  "delivery_id",
                  "sent_at",
                  "raised_at",
                  "alert",
                  "organization",
                  "group",
                  "node",
                  "text",
                  "url"
                ],
                "properties": {
                  "event": {
                    "type": "string",
                    "const": "alert"
                  },
                  "delivery_id": {
                    "type": "string",
                    "description": "Identifier of this delivery, a ULID; the same value is sent in the delivery header. A retry repeats it."
                  },
                  "sent_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "When the delivery was queued; every retry repeats it."
                  },
                  "raised_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "When the alert was raised."
                  },
                  "alert": {
                    "type": "object",
                    "required": [
                      "kind",
                      "level"
                    ],
                    "properties": {
                      "kind": {
                        "type": "string",
                        "description": "The alert's kind, such as `capacity_deficit_started`, `healing_started` or `integration_suspended`."
                      },
                      "level": {
                        "type": "string",
                        "enum": [
                          "critical",
                          "warning",
                          "notice"
                        ]
                      }
                    }
                  },
                  "organization": {
                    "$ref": "#/components/schemas/DeliveryOrganization"
                  },
                  "group": {
                    "oneOf": [
                      {
                        "$ref": "#/components/schemas/DeliveryGroup"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "node": {
                    "oneOf": [
                      {
                        "$ref": "#/components/schemas/DeliveryNode"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  },
                  "text": {
                    "type": "string",
                    "description": "The alert's line from the alert mail, in English."
                  },
                  "url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "uri",
                    "description": "The group's timeline on the panel, or null."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Any 2xx acknowledges the delivery. Any other answer, a redirect or no answer within 10 seconds is retried: eight attempts over about 38 minutes, and an endpoint that fails for 30 minutes without a success is suspended and probed once an hour."
          }
        }
      }
    },
    "ping": {
      "post": {
        "operationId": "integrationPing",
        "x-hetscale-status": "implemented",
        "summary": "The Integrations page's test",
        "description": "Sent when a member sends a test from the Integrations page. `event` is `ping`. It goes even to a suspended integration, and a success resumes it.",
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookSignature"
          },
          {
            "$ref": "#/components/parameters/webhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/webhookDelivery"
          },
          {
            "$ref": "#/components/parameters/webhookEvent"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "event",
                  "delivery_id",
                  "sent_at",
                  "organization",
                  "group",
                  "node",
                  "text",
                  "url"
                ],
                "properties": {
                  "event": {
                    "type": "string",
                    "const": "ping"
                  },
                  "delivery_id": {
                    "type": "string",
                    "description": "Identifier of this delivery, a ULID; the same value is sent in the delivery header. A retry repeats it."
                  },
                  "sent_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "When the delivery was queued; every retry repeats it."
                  },
                  "organization": {
                    "$ref": "#/components/schemas/DeliveryOrganization"
                  },
                  "group": {
                    "type": "null"
                  },
                  "node": {
                    "type": "null"
                  },
                  "text": {
                    "type": "string",
                    "examples": [
                      "A test delivery from Hetscale."
                    ]
                  },
                  "url": {
                    "type": "null"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Any 2xx acknowledges the delivery. Any other answer, a redirect or no answer within 10 seconds is retried: eight attempts over about 38 minutes, and an endpoint that fails for 30 minutes without a success is suspended and probed once an hour."
          }
        }
      }
    },
    "node.created": {
      "post": {
        "operationId": "nodeCreated",
        "x-hetscale-status": "implemented",
        "summary": "A new node is booted and reachable",
        "description": "Sent to a webhook group's endpoint once per node, when its server has booted and is reachable (after a boot grace of 30 seconds), so the customer's pipeline deploys the current release to it. The node joins the load balancer once the pipeline reports it `ready` and takes traffic when its health check then passes; both must happen before `deadline_at`, or the node is destroyed and an alert is raised, and three consecutive deploy failures pause the group. `group.generation` is the generation the node was created for: send it back, with `release.id`, in the node's `ready` and `failed` reports. Deliveries are deduplicated by `delivery_id`, never by node id; a delivery is retried for about 38 minutes and then given up.",
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookSignature"
          },
          {
            "$ref": "#/components/parameters/webhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/webhookDelivery"
          },
          {
            "$ref": "#/components/parameters/webhookEvent"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["event", "delivery_id", "sent_at", "group", "node", "release", "deadline_at"],
                "properties": {
                  "event": {
                    "type": "string",
                    "const": "node.created",
                    "description": "The event name."
                  },
                  "delivery_id": {
                    "type": "string",
                    "description": "Identifier of this delivery, a ULID; the same value is sent in the delivery header."
                  },
                  "sent_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "When the delivery was sent."
                  },
                  "group": {
                    "$ref": "#/components/schemas/Group"
                  },
                  "node": {
                    "$ref": "#/components/schemas/Node"
                  },
                  "release": {
                    "$ref": "#/components/schemas/Release"
                  },
                  "deadline_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "When the node's readiness deadline expires; the node is then destroyed and an alert is raised."
                  }
                }
              },
              "example": {
                "event": "node.created",
                "delivery_id": "01J8X4V9K2N7Q3R5S6T7V8W9XA",
                "sent_at": "2026-09-15T13:02:11Z",
                "group": {
                  "id": "grp_01J8X4V9K2N7Q3R5S6T7V8W9XB",
                  "name": "api-prod",
                  "generation": 42
                },
                "node": {
                  "id": "node_01J8X4V9K2N7Q3R5S6T7V8W9XC",
                  "server_id": 12345678,
                  "name": "hetscale-api-prod-01J8X4V9K2N7Q3R5S6T7V8W9XC",
                  "class": "managed",
                  "phase": "DEPLOYING",
                  "ipv4": "203.0.113.10",
                  "ipv6": "2001:db8::10",
                  "private_ip": "10.0.1.10",
                  "location": "nbg1",
                  "server_type": "cx23",
                  "release": {
                    "id": "rel_01J8X4V9K2N7Q3R5S6T7V8W9XD",
                    "ref": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
                    "build_id": "4711",
                    "source": "api",
                    "created_at": "2026-09-15T12:40:00Z"
                  },
                  "build_id": null,
                  "phase_entered_at": "2026-09-15T13:02:10Z"
                },
                "release": {
                  "id": "rel_01J8X4V9K2N7Q3R5S6T7V8W9XD",
                  "ref": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
                  "build_id": "4711",
                  "source": "api",
                  "created_at": "2026-09-15T12:40:00Z"
                },
                "deadline_at": "2026-09-15T13:17:11Z"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "$ref": "#/components/responses/WebhookAcknowledged"
          }
        }
      }
    },
    "node.terminating": {
      "post": {
        "operationId": "nodeTerminating",
        "x-hetscale-status": "implemented",
        "summary": "A node is leaving the group",
        "description": "Sent to a webhook group's endpoint once per node, when the node enters `DRAINING`, before it is removed from the load balancer. After removal the load balancer sends no new connections to the node and keeps existing ones alive for the group's drain window (default 60 seconds, at most 300 seconds); the node is then shut down and deleted. Deliveries are deduplicated by `delivery_id`, never by node id.",
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookSignature"
          },
          {
            "$ref": "#/components/parameters/webhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/webhookDelivery"
          },
          {
            "$ref": "#/components/parameters/webhookEvent"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["event", "delivery_id", "sent_at", "group", "node", "reason"],
                "properties": {
                  "event": {
                    "type": "string",
                    "const": "node.terminating",
                    "description": "The event name."
                  },
                  "delivery_id": {
                    "type": "string",
                    "description": "Identifier of this delivery, a ULID; the same value is sent in the delivery header."
                  },
                  "sent_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "When the delivery was sent."
                  },
                  "group": {
                    "$ref": "#/components/schemas/Group"
                  },
                  "node": {
                    "type": "object",
                    "description": "The node that is leaving, identified by the same values as in the inventory API.",
                    "required": ["id", "server_id", "name"],
                    "properties": {
                      "id": {
                        "type": "string",
                        "description": "Node identifier, a ULID prefixed with `node_`."
                      },
                      "server_id": {
                        "type": "integer",
                        "description": "Id of the server in the customer's Hetzner Cloud project."
                      },
                      "name": {
                        "type": "string",
                        "description": "Server name in Hetzner Cloud: `hetscale-<group prefix>-<number>` for a server Hetscale created (for example `hetscale-web-7`), `hetscale-<group prefix>-0` for a promoted seed, the customer's own name for an adopted server."
                      }
                    }
                  },
                  "reason": {
                    "type": ["string", "null"],
                    "description": "Why the node is leaving, as Hetscale recorded it: for example `scale_in`, `rehearsal`, `user_destroy`, `heal` or `manual`."
                  }
                }
              },
              "example": {
                "event": "node.terminating",
                "delivery_id": "01J8X4V9K2N7Q3R5S6T7V8W9XE",
                "sent_at": "2026-09-15T14:55:20Z",
                "group": {
                  "id": "grp_01J8X4V9K2N7Q3R5S6T7V8W9XB",
                  "name": "api-prod",
                  "generation": 42
                },
                "node": {
                  "id": "node_01J8X4V9K2N7Q3R5S6T7V8W9XC",
                  "server_id": 12345678,
                  "name": "hetscale-api-prod-01J8X4V9K2N7Q3R5S6T7V8W9XC"
                },
                "reason": "scale-in: CPU below 35% for 10 minutes"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "$ref": "#/components/responses/WebhookAcknowledged"
          }
        }
      }
    },
    "group.release_changed": {
      "post": {
        "operationId": "groupReleaseChanged",
        "x-hetscale-status": "implemented",
        "summary": "The group's current release changed",
        "description": "Sent to a webhook group's endpoint when its `current` release changes: through the releases endpoint, or when a member makes an earlier release current again on the group's Deployment page. Every node created from now on names the new release in `node.created`; nodes already running keep the release they were deployed with until a member's rollout replaces them. The group carries its new generation.",
        "parameters": [
          {
            "$ref": "#/components/parameters/webhookSignature"
          },
          {
            "$ref": "#/components/parameters/webhookTimestamp"
          },
          {
            "$ref": "#/components/parameters/webhookDelivery"
          },
          {
            "$ref": "#/components/parameters/webhookEvent"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["event", "delivery_id", "sent_at", "group", "previous_release", "release"],
                "properties": {
                  "event": {
                    "type": "string",
                    "const": "group.release_changed",
                    "description": "The event name."
                  },
                  "delivery_id": {
                    "type": "string",
                    "description": "Identifier of this delivery, a ULID; the same value is sent in the delivery header."
                  },
                  "sent_at": {
                    "type": "string",
                    "format": "date-time",
                    "description": "When the delivery was sent."
                  },
                  "group": {
                    "$ref": "#/components/schemas/Group"
                  },
                  "previous_release": {
                    "$ref": "#/components/schemas/Release"
                  },
                  "release": {
                    "$ref": "#/components/schemas/Release"
                  }
                }
              },
              "example": {
                "event": "group.release_changed",
                "delivery_id": "01J8X4V9K2N7Q3R5S6T7V8W9XF",
                "sent_at": "2026-09-15T12:40:01Z",
                "group": {
                  "id": "grp_01J8X4V9K2N7Q3R5S6T7V8W9XB",
                  "name": "api-prod",
                  "generation": 42
                },
                "previous_release": {
                  "id": "rel_01J8X4V9K2N7Q3R5S6T7V8W9XG",
                  "ref": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
                  "build_id": "4710",
                  "source": "api",
                  "created_at": "2026-09-14T09:15:00Z"
                },
                "release": {
                  "id": "rel_01J8X4V9K2N7Q3R5S6T7V8W9XD",
                  "ref": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
                  "build_id": "4711",
                  "source": "api",
                  "created_at": "2026-09-15T12:40:00Z"
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "$ref": "#/components/responses/WebhookAcknowledged"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An organisation's API token from the panel's API tokens page, sent as `Authorization: Bearer <id>|<secret>`. A token acts for the member who created it and stops working when that member leaves the organisation. It may do only what its abilities allow: `nodes:report`, `releases:write`, `groups:lock`, `nodes:read`, `rollouts:write`, `metrics:read`, `groups:read` and `groups:write`. An ability counts only while the member's role holds it: reads for every member, the node reports, releases, the deploy lock and the rollout for an operator, a group's changes for an admin; an ability above the role is answered 403 `forbidden`."
      }
    },
    "parameters": {
      "groupId": {
        "name": "groupId",
        "in": "path",
        "required": true,
        "description": "Group identifier: the group's ULID in lower case, prefixed with `grp_`. Read in either case.",
        "schema": {
          "type": "string"
        },
        "example": "grp_01j8x4v9k2n7q3r5s6t7v8w9xb"
      },
      "nodeId": {
        "name": "nodeId",
        "in": "path",
        "required": true,
        "description": "Node identifier: the node's ULID in lower case, prefixed with `node_`. Read in either case.",
        "schema": {
          "type": "string"
        },
        "example": "node_01j8x4v9k2n7q3r5s6t7v8w9xc"
      },
      "idempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Optional on every state-changing request: 1 to 255 visible ASCII characters, kept for 24 hours per organisation and bound to the token that first sent it. A retry by the same token with the same key and the same request (method, path, query string and exact body) gets the first answer again, marked `Idempotent-Replayed: true`, instead of a second effect. The same key with a different request, or from another token of the organisation, is answered 422 `idempotency_key_reused`; while the first request still runs, its own token gets 409 `idempotency_key_in_flight`. A request that failed validation or on the server leaves no answer behind, so its retry runs again.",
        "schema": {
          "type": "string",
          "minLength": 1,
          "maxLength": 255
        },
        "example": "deploy-4711-node-01j8x4v9k2n7q3r5s6t7v8w9xc"
      },
      "webhookSignature": {
        "name": "X-Hetscale-Signature",
        "in": "header",
        "required": true,
        "description": "One `v1=<hex>` value per secret that signs now, comma-separated: the HMAC-SHA256 of `<timestamp>.<body>` with the endpoint's signing secret, where `<timestamp>` is `X-Hetscale-Timestamp` and `<body>` the raw request body. After a rotation the previous secret signs alongside the new one for 24 hours. Accept the delivery when one value matches, before you parse the body.",
        "schema": {
          "type": "string"
        }
      },
      "webhookTimestamp": {
        "name": "X-Hetscale-Timestamp",
        "in": "header",
        "required": true,
        "description": "Unix time of this attempt, in seconds. Every retry is signed again with its own timestamp while the body stays the same; refuse a delivery whose timestamp is more than five minutes old.",
        "schema": {
          "type": "string"
        }
      },
      "webhookDelivery": {
        "name": "X-Hetscale-Delivery",
        "in": "header",
        "required": true,
        "description": "The delivery id, equal to `delivery_id` in the body. A retry repeats it with the same body, so a receiver that stores the ids it has seen can process a duplicate safely.",
        "schema": {
          "type": "string"
        }
      },
      "webhookEvent": {
        "name": "X-Hetscale-Event",
        "in": "header",
        "required": true,
        "description": "The event, equal to `event` in the body.",
        "schema": {
          "type": "string"
        }
      }
    },
    "responses": {
      "GroupDocument": {
        "description": "The group's document.",
        "headers": {
          "ETag": {
            "description": "A digest of the `settings` block: it changes when a setting does, never with the tick. Send it back in `If-Match`.",
            "schema": {
              "type": "string"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/GroupDocument"
            }
          }
        }
      },
      "GroupConflict": {
        "description": "The group or the project is not in a state for this: `not_live`, `rehearsal_running`, `release_missing`, `already_archived`, `not_archived`, `rehearsing`, `servers_running`, `project_not_read`, `case_declined`, `case_not_yet`, `case_needs_load_balancer`, `connection_inactive`, `load_balancer_already_observed`, `load_balancer_archived`, `load_balancer_observed_elsewhere` or `seed_already_observed`. Also `idempotency_key_in_flight`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "GroupRefused": {
        "description": "The body failed validation (`validation_failed`), a value is one the group does not take (`capacity_out_of_bounds`, `settings_out_of_bounds`, `fallback_type_unfit`, `fallback_cap_too_low` with the euros `needed`, `ssh_key_unknown`), or the load balancer or the seed cannot be observed (`load_balancer_missing`, `seed_not_behind_load_balancer`, `seed_created_by_hetscale`, `seed_architecture_unknown`, `seed_outside_zone`), or Kubernetes manages the load balancer or the seed (`kubernetes_managed`: the balancer carries the cloud controller manager's label `hcloud-ccm/service-uid`, or the seed the Cluster Autoscaler's `hcloud/node-group`). Also `idempotency_key_reused`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "PreconditionFailed": {
        "description": "A setting changed since the `ETag` sent in `If-Match` (`precondition_failed`); nothing was written.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NodeReportAccepted": {
        "description": "The report was taken, or had been taken before; the node as it stands.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": ["node"],
              "properties": {
                "node": {
                  "$ref": "#/components/schemas/NodeState"
                }
              }
            },
            "example": {
              "node": {
                "id": "node_01j8x4v9k2n7q3r5s6t7v8w9xc",
                "phase": "DEPLOYING",
                "generation": 42,
                "release": "rel_01j8x4v9k2n7q3r5s6t7v8w9xd",
                "ready_reported_at": "2026-09-15T13:09:40Z",
                "failed_reported_at": null
              }
            }
          }
        }
      },
      "BadRequest": {
        "description": "The Idempotency-Key header is not 1 to 255 visible ASCII characters (`idempotency_key_invalid`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "The API token is missing, unknown, expired or revoked, or the member it acts for left the organisation (`unauthenticated`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "The token lacks the ability the operation needs (`forbidden`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "The group or node does not exist or belongs to another organisation (`not_found`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NodeReportConflict": {
        "description": "The report changed nothing: it names another generation (`stale_generation`) or release (`stale_release`), the node no longer waits for its deploy (`not_in_readiness`), or a failure was reported before a ready report (`failure_reported`); the body carries the node as it stands. Also `idempotency_key_in_flight` while a request with the same key still runs.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            },
            "example": {
              "message": "The report names another generation than the node's.",
              "code": "stale_generation",
              "node": {
                "id": "node_01j8x4v9k2n7q3r5s6t7v8w9xc",
                "phase": "DEPLOYING",
                "generation": 42,
                "release": "rel_01j8x4v9k2n7q3r5s6t7v8w9xd",
                "ready_reported_at": "2026-09-15T13:09:40Z",
                "failed_reported_at": null
              }
            }
          }
        }
      },
      "ValidationFailed": {
        "description": "The request body failed validation (`validation_failed`, with the messages per field), or its Idempotency-Key was sent with a different request or by another token (`idempotency_key_reused`).",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "The organisation's API rate limit, 60 requests a minute by default, is exhausted (`rate_limited`); retry after the seconds in `Retry-After`.",
        "headers": {
          "Retry-After": {
            "description": "Seconds until the organisation may call again.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ReleaseCreated": {
        "description": "The release was recorded and is now the group's current release; the group with its new generation.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": ["release", "group"],
              "properties": {
                "release": {
                  "$ref": "#/components/schemas/Release"
                },
                "group": {
                  "$ref": "#/components/schemas/Group"
                }
              }
            },
            "example": {
              "release": {
                "id": "rel_01j8x4v9k2n7q3r5s6t7v8w9xe",
                "version": 4,
                "ref": "231456789",
                "build_id": "4712",
                "source": "api",
                "created_at": "2026-09-15T12:40:00Z"
              },
              "group": {
                "id": "grp_01j8x4v9k2n7q3r5s6t7v8w9xb",
                "name": "api-prod",
                "generation": 43
              }
            }
          }
        }
      },
      "ReleaseCurrent": {
        "description": "The group already points at this image and build; nothing changed.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "required": ["release", "group"],
              "properties": {
                "release": {
                  "$ref": "#/components/schemas/Release"
                },
                "group": {
                  "$ref": "#/components/schemas/Group"
                }
              }
            }
          }
        }
      },
      "ReleaseConflict": {
        "description": "The group's state stands in the way: not Live or paused (`not_live`), releases from Coolify deployments (`not_snapshot_group`), no release v1 (`release_missing`) or a snapshot of the seed in progress (`snapshot_in_progress`). Also `idempotency_key_in_flight`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ReleaseRefused": {
        "description": "The ref cannot become the release: `ref_invalid`, `ref_mutable` (a `:latest` tag), `image_not_found`, `image_not_snapshot`, `image_not_available`, `architecture_mismatch` or `image_too_large`. Also `validation_failed` and `idempotency_key_reused`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ProviderError": {
        "description": "Hetzner refused to describe the image with the project's token (`provider_error`); a retry will not help until the connection is fixed.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ProviderUnavailable": {
        "description": "Hetzner could not be asked just now (`provider_unavailable`), or Hetscale could not open the stored secrets: its key service did not answer (`key_service_unavailable`) or its master key is unavailable (`secrets_unavailable`); retry, after the seconds in `Retry-After` when it is sent.",
        "headers": {
          "Retry-After": {
            "description": "Seconds until Hetzner takes requests from the project again, when known.",
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "WebhookAcknowledged": {
        "description": "Any 2xx status acknowledges the delivery. Failed deliveries are retried with backoff."
      },
      "LockConflict": {
        "description": "The group is not Live or paused (`not_live`). Also `idempotency_key_in_flight`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RolloutConflict": {
        "description": "The group is not Live (`not_live`), a rollout already runs (`already_running`) or every server runs the current release and template (`nothing_to_replace`), leaving out a server the load balancer no longer lists as a target, which a rollout does not replace. Also `idempotency_key_in_flight`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NodeDeployedConflict": {
        "description": "The node's group takes no in-place report (`not_webhook_group`), or the node is not managed (`node_not_managed`) or not serving (`node_not_active`). Also `idempotency_key_in_flight`.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "PolicyRule": {
        "type": "object",
        "required": ["metric", "threshold", "window_seconds"],
        "additionalProperties": false,
        "properties": {
          "metric": {
            "type": "string",
            "enum": [
              "cpu_percent",
              "lb_requests_per_second",
              "lb_open_connections"
            ],
            "description": "What the rule reads: the servers' CPU in percent, or the load balancer's requests per second or open connections per server."
          },
          "threshold": {
            "type": "number",
            "description": "The value the metric must stay above (scale-out) or below (scale-in) for the whole window."
          },
          "window_seconds": {
            "type": "integer",
            "description": "How long the metric must hold, in seconds."
          }
        }
      },
      "GroupSettings": {
        "type": "object",
        "description": "What a member changes on a group: the panel's Settings page, the SSH keys of new servers and the decision mode. It is the body `PATCH /groups/{groupId}` takes, whole or in part, and its digest is the group's `ETag`. The panel's Export settings downloads this block alone. A field it does not list, at any depth, is refused with `validation_failed` under the field's path, and so is a whole group document sent back instead of this block. `minimum` and `maximum` give a number's range, `x-hetscale-initial` the value a new group starts with.",
        "additionalProperties": false,
        "properties": {
          "min": {
            "type": "integer",
            "description": "The fewest servers the group keeps. Bounded by the group: at least the servers that cannot leave, at most `max`."
          },
          "max": {
            "type": "integer",
            "description": "The most servers the group runs: one under the load balancer's target limit once its targets that are not servers of the group are taken off, so a replacement has a target beside the server it replaces, and within the room the private network new servers join has left, one place kept free for a replacement there too."
          },
          "policy": {
            "type": "object",
            "description": "The scaling policy. A group keeps the values of the preset it took until a member takes the preset's values of today. A named preset sets every other field, which is accepted only when it matches the values the group keeps or the preset's values today; a body that matches only today's takes all of today's values, not just the fields it sends; naming the group's own preset alone keeps its values; `target` writes them from its target (one metric and the value to hold, on Pro and Team; `plan_required` otherwise), from the values the group keeps or from today's Balanced, and they too are accepted only when they match; `custom` takes each field, and a field left out keeps its value. Scale-in must stay stricter than scale-out.",
            "additionalProperties": false,
            "properties": {
              "preset": {
                "type": "string",
                "enum": [
                  "balanced",
                  "conservative",
                  "aggressive",
                  "custom",
                  "target"
                ]
              },
              "target": {
                "type": ["object", "null"],
                "description": "What a target-tracking policy holds; null for the other presets. Scale-out adds the servers that bring the metric back to the value; scale-in removes one at a time once the remaining servers would stay below 90% of it.",
                "required": ["metric", "value"],
                "additionalProperties": false,
                "properties": {
                  "metric": {
                    "type": "string",
                    "enum": [
                      "cpu_percent",
                      "lb_requests_per_second",
                      "lb_open_connections"
                    ]
                  },
                  "value": {
                    "type": "number",
                    "description": "CPU from 10 to 90 percent; requests per second or open connections per server from 1."
                  }
                }
              },
              "scale_out": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "rules": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/PolicyRule"
                    }
                  }
                }
              },
              "scale_in": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "rules": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/schemas/PolicyRule"
                    }
                  }
                }
              },
              "cooldown_seconds": {
                "type": "integer",
                "description": "The wait after an action before the next scale-in."
              },
              "warm_up_seconds": {
                "type": "integer",
                "description": "How long a new server counts as capacity before its own metrics do."
              },
              "max_rate_of_change": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "nodes": {
                    "type": "integer",
                    "description": "The servers one decision may add."
                  },
                  "window_seconds": {
                    "type": "integer"
                  }
                }
              },
              "cost_guard": {
                "type": "object",
                "additionalProperties": false,
                "properties": {
                  "creations": {
                    "type": "integer",
                    "description": "The servers Hetscale may create within the window."
                  },
                  "window_seconds": {
                    "type": "integer"
                  }
                }
              }
            }
          },
          "drain_window_seconds": {
            "type": "integer",
            "minimum": 10,
            "maximum": 300,
            "x-hetscale-initial": 60,
            "description": "How long a leaving server keeps its connections after it leaves the load balancer: 10 to 300 seconds."
          },
          "readiness_deadline_seconds": {
            "type": "integer",
            "minimum": 300,
            "maximum": 3600,
            "x-hetscale-initial": 900,
            "description": "How long a new server may take to pass the load balancer's health check: 300 to 3,600 seconds."
          },
          "keep_failed_nodes_seconds": {
            "type": "integer",
            "minimum": 0,
            "maximum": 7200,
            "x-hetscale-initial": 0,
            "description": "How long a failed server is kept for a look before it is deleted: 0 to 7,200 seconds."
          },
          "euro_ceiling": {
            "type": ["integer", "null"],
            "minimum": 1,
            "maximum": 1000000,
            "x-hetscale-initial": null,
            "description": "The monthly euros the group's added servers may cost at list prices, 1 to 1,000,000; null for none."
          },
          "release_age_warning_days": {
            "type": ["integer", "null"],
            "minimum": 1,
            "maximum": 365,
            "x-hetscale-initial": 30,
            "description": "The age in days, 1 to 365, at which the current release warns; null for never."
          },
          "fallback": {
            "type": "object",
            "description": "What happens when the group's type is out of stock.",
            "additionalProperties": false,
            "properties": {
              "mode": {
                "type": ["string", "null"],
                "enum": ["single_type", "mixed_type", "cheapest_type", null],
                "description": "`single_type` waits for the group's type, `mixed_type` tries `server_types` in order, `cheapest_type` takes the cheapest fitting type in stock under `price_cap`; null keeps the mode."
              },
              "server_types": {
                "type": "array",
                "maxItems": 3,
                "items": {
                  "type": "string"
                },
                "description": "One to three different types that fit the group's type, most preferred first; kept while another mode is chosen."
              },
              "price_cap": {
                "type": ["integer", "null"],
                "minimum": 1,
                "maximum": 1000,
                "x-hetscale-initial": null,
                "description": "Whole euros a server may cost more each month than the group's type, 1 to 1,000; null keeps the cap. Refused with `fallback_cap_too_low` and the euros needed when no fitting type falls under it."
              }
            }
          },
          "hold_scale_in_while_sold_out": {
            "type": "boolean",
            "x-hetscale-initial": false,
            "description": "Keep the servers a scale-in would remove while the central stock poll, read within the last 3 minutes, flags every type and location the group's next create would try as out of stock; at most 24 hours per stock-out. Off by default."
          },
          "placement": {
            "type": ["string", "null"],
            "enum": ["spread", "none", null],
            "description": "`spread` puts new servers on different physical hosts; null keeps it."
          },
          "ssh_key_ids": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "The Hetzner ids of the public keys of the group's project that new servers get, among those the latest read of the project found."
          },
          "decision_mode": {
            "type": "string",
            "enum": ["automatic", "propose"],
            "description": "`automatic` acts on the policy's decisions, `propose` waits for a member to approve each one on the panel."
          }
        }
      },
      "GroupSummary": {
        "type": "object",
        "required": [
          "id",
          "name",
          "state",
          "project",
          "min",
          "desired",
          "max",
          "archived_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Group identifier, a ULID prefixed with `grp_`."
          },
          "name": {
            "type": "string"
          },
          "state": {
            "type": "string",
            "enum": ["observe", "rehearsing", "live", "paused"]
          },
          "project": {
            "type": ["object", "null"],
            "required": ["id", "name"],
            "properties": {
              "id": {
                "type": "string",
                "description": "Project identifier, a ULID prefixed with `prj_`."
              },
              "name": {
                "type": "string"
              }
            }
          },
          "min": {
            "type": "integer"
          },
          "desired": {
            "type": "integer"
          },
          "max": {
            "type": "integer"
          },
          "archived_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "When the group went to the Archive, or null."
          }
        }
      },
      "GroupDocument": {
        "description": "A group as one document, also its JSON export: the panel's Export JSON downloads the same. A webhook secret, the template's user data and any token never appear.",
        "allOf": [
          {
            "$ref": "#/components/schemas/GroupSummary"
          },
          {
            "type": "object",
            "required": [
              "server_prefix",
              "label_selector",
              "load_balancer",
              "architecture",
              "capacity",
              "generation",
              "settings",
              "template",
              "deployment",
              "schedules"
            ],
            "properties": {
              "server_prefix": {
                "type": "string",
                "description": "The middle of the group's server names, `hetscale-<prefix>-<n>`."
              },
              "label_selector": {
                "type": "string",
                "description": "The Hetzner label selector of the servers Hetscale manages in this group, `hetscale.dev/managed=true,hetscale.dev/group=<ulid>`, for a monitoring scrape or an inventory. The group label holds the group's ulid without the `grp_` of its id. It matches every server that carries these labels, in every phase: the servers Hetscale created, a quarantined server and a seed after its promotion; never the seed before it nor the customer's other servers. Never use it to add load balancer targets or to apply a firewall: Hetscale adds a server to the load balancer only once it may serve, and takes it out through the drain.",
                "examples": [
                  "hetscale.dev/managed=true,hetscale.dev/group=01j8x4m2k7q9r3t5v6w8y0z1ab"
                ]
              },
              "load_balancer": {
                "type": "object",
                "properties": {
                  "id": {
                    "type": "integer",
                    "description": "The load balancer's Hetzner id."
                  },
                  "network_zone": {
                    "type": "string"
                  }
                }
              },
              "architecture": {
                "type": "string",
                "enum": ["x86", "arm", "unknown"]
              },
              "pause_reason": {
                "type": ["string", "null"],
                "description": "Why a paused group paused, such as `user` or `bad_release_brake`."
              },
              "paused_at": {
                "type": ["string", "null"],
                "format": "date-time",
                "description": "When the group paused."
              },
              "emergency_stopped_at": {
                "type": ["string", "null"],
                "format": "date-time",
                "description": "When an emergency stop began, while it holds."
              },
              "maintenance_until": {
                "type": ["string", "null"],
                "format": "date-time",
                "description": "Until when no server is taken out for scale-in."
              },
              "locked_until": {
                "type": ["string", "null"],
                "format": "date-time",
                "description": "Until when the deploy lock holds."
              },
              "lock_reason": {
                "type": ["string", "null"]
              },
              "observe": {
                "type": "object",
                "properties": {
                  "ends_at": {
                    "type": ["string", "null"],
                    "format": "date-time",
                    "description": "When the Observe trial ends."
                  },
                  "ended_at": {
                    "type": ["string", "null"],
                    "format": "date-time",
                    "description": "When it ended."
                  }
                }
              },
              "go_live": {
                "type": "object",
                "description": "How far the group has come on the panel's path to Live.",
                "properties": {
                  "protection_confirmed_at": {
                    "type": ["string", "null"],
                    "format": "date-time",
                    "description": "When a member confirmed the delete protection."
                  },
                  "rehearsal_passed_at": {
                    "type": ["string", "null"],
                    "format": "date-time",
                    "description": "When a Rehearsal passed."
                  }
                }
              },
              "capacity": {
                "type": "object",
                "properties": {
                  "min": {
                    "type": "integer"
                  },
                  "desired": {
                    "type": "integer"
                  },
                  "max": {
                    "type": "integer"
                  },
                  "active": {
                    "type": "integer",
                    "description": "Servers that serve and count as capacity."
                  },
                  "in_flight": {
                    "type": "integer",
                    "description": "Servers being created, booted, deployed or health-checked."
                  }
                }
              },
              "generation": {
                "type": "integer"
              },
              "settings": {
                "$ref": "#/components/schemas/GroupSettings"
              },
              "template": {
                "type": ["object", "null"],
                "description": "The latest template version new servers are created from; null before the first release.",
                "properties": {
                  "version": {
                    "type": "integer"
                  },
                  "server_type": {
                    "type": "string"
                  },
                  "locations": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "image": {
                    "type": ["string", "null"]
                  },
                  "network_ids": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "firewall_ids": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "ssh_key_ids": {
                    "type": "array",
                    "items": {
                      "type": "integer"
                    }
                  },
                  "labels": {
                    "type": "object",
                    "description": "Labels of the customer's own that the template adds to new servers; it adds none today. Hetscale's own labels are not listed here: `label_selector` finds the servers by them.",
                    "additionalProperties": {
                      "type": "string"
                    }
                  },
                  "public_ipv4": {
                    "type": "boolean"
                  },
                  "public_ipv6": {
                    "type": "boolean"
                  },
                  "placement": {
                    "type": "string",
                    "enum": ["spread", "none"]
                  },
                  "fallback_server_types": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "created_at": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              },
              "deployment": {
                "type": "object",
                "properties": {
                  "adapter": {
                    "type": "string",
                    "enum": ["snapshot", "webhook", "coolify"]
                  },
                  "webhook_url": {
                    "type": ["string", "null"],
                    "format": "uri",
                    "description": "The webhook adapter's endpoint; never its secret."
                  },
                  "release": {
                    "oneOf": [
                      {
                        "$ref": "#/components/schemas/Release"
                      },
                      {
                        "type": "null"
                      }
                    ]
                  }
                }
              },
              "schedules": {
                "type": "array",
                "description": "The group's recurring capacity rules, read only (scheduled scaling is Pro).",
                "items": {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string"
                    },
                    "weekdays": {
                      "type": "array",
                      "items": {
                        "type": "integer",
                        "minimum": 1,
                        "maximum": 7
                      }
                    },
                    "starts_at": {
                      "type": "string",
                      "description": "HH:MM in the organisation's time zone."
                    },
                    "ends_at": {
                      "type": "string"
                    },
                    "floor": {
                      "type": ["integer", "null"]
                    },
                    "ceiling": {
                      "type": ["integer", "null"]
                    },
                    "enabled": {
                      "type": "boolean"
                    }
                  }
                }
              },
              "created_at": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        ]
      },
      "Project": {
        "type": "object",
        "required": ["id", "name", "status", "token_level", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Project identifier, a ULID prefixed with `prj_`."
          },
          "name": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["active", "revoked", "deleted"],
            "description": "Whether Hetscale can still reach the project with its token."
          },
          "token_level": {
            "type": "string",
            "enum": ["read", "read_write"]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "CreateGroup": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "project",
          "load_balancer_id",
          "seed_server_id",
          "name",
          "answers"
        ],
        "properties": {
          "project": {
            "type": "string",
            "description": "The project's `prj_` id, from `GET /projects`."
          },
          "load_balancer_id": {
            "type": "integer",
            "description": "The Hetzner id of the load balancer to observe."
          },
          "seed_server_id": {
            "type": "integer",
            "description": "The Hetzner id of one of your servers behind it, the seed new servers are made from."
          },
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "server_prefix": {
            "type": ["string", "null"],
            "maxLength": 20,
            "description": "The middle of the group's server names; by default one the name suggests."
          },
          "answers": {
            "type": "object",
            "additionalProperties": false,
            "description": "The panel's case questions, assessed against the project's latest read.",
            "required": [
              "workload",
              "database",
              "deploy",
              "behind_cloudflare",
              "two_apps",
              "campaigns"
            ],
            "properties": {
              "workload": {
                "type": "string",
                "enum": [
                  "stateless_app",
                  "stateful_single_box",
                  "queue_worker",
                  "no_server_yet"
                ]
              },
              "database": {
                "type": "string",
                "enum": ["managed", "own_server", "same_server"]
              },
              "deploy": {
                "type": "string",
                "enum": [
                  "snapshot",
                  "ci_ssh",
                  "coolify",
                  "kamal",
                  "forge_ploi",
                  "other"
                ]
              },
              "behind_cloudflare": {
                "type": "boolean"
              },
              "two_apps": {
                "type": "boolean"
              },
              "campaigns": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "DeliveryOrganization": {
        "type": "object",
        "description": "The organisation an integration's delivery comes from.",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Organisation identifier, a ULID prefixed with `org_`."
          },
          "name": {
            "type": "string"
          }
        }
      },
      "DeliveryGroup": {
        "type": "object",
        "description": "The group a delivery is about.",
        "required": [
          "id",
          "name"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Group identifier, a ULID prefixed with `grp_`."
          },
          "name": {
            "type": "string"
          }
        }
      },
      "DeliveryNode": {
        "type": "object",
        "description": "The server a delivery is about.",
        "required": [
          "id",
          "name",
          "server_id"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Node identifier, a ULID prefixed with `node_`."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The server's name in Hetzner Cloud."
          },
          "server_id": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Id of the server in the customer's Hetzner Cloud project."
          }
        }
      },
      "Group": {
        "type": "object",
        "required": ["id", "name", "generation"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Group identifier, a ULID prefixed with `grp_`."
          },
          "name": {
            "type": "string",
            "description": "Name of the group as shown in the panel."
          },
          "generation": {
            "type": "integer",
            "description": "Counter incremented on every template or release change; a scaling decision made against an older generation is discarded. In `node.created`, the generation the node was created for."
          }
        }
      },
      "Node": {
        "type": "object",
        "required": ["id", "server_id", "name", "class", "phase", "ipv4", "ipv6", "private_ip", "location", "server_type", "release", "build_id", "phase_entered_at", "older"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Node identifier, a ULID prefixed with `node_`."
          },
          "server_id": {
            "type": ["integer", "null"],
            "description": "Id of the server in the customer's Hetzner Cloud project, or null before it is created."
          },
          "name": {
            "type": ["string", "null"],
            "description": "Server name in Hetzner Cloud: `hetscale-<group prefix>-<number>` for a server Hetscale created (for example `hetscale-web-7`), `hetscale-<group prefix>-0` for a promoted seed, the customer's own name for an adopted server."
          },
          "class": {
            "type": "string",
            "enum": ["adopted", "managed"],
            "description": "`adopted` is the seed server the customer already ran, which is never scaled in or replaced; `managed` nodes were created by Hetscale and have the full lifecycle."
          },
          "phase": {
            "type": "string",
            "enum": [
              "REQUESTED",
              "PROVISIONING",
              "BOOTING",
              "DEPLOYING",
              "HEALTH_CHECKING",
              "ACTIVE",
              "DRAINING",
              "TERMINATING",
              "TERMINATED",
              "PROVISION_FAILED",
              "DEPLOY_FAILED",
              "HEALTH_FAILED",
              "DRAIN_TIMEOUT",
              "TERMINATION_FAILED"
            ],
            "description": "Current phase in the node lifecycle; only `ACTIVE` nodes receive traffic."
          },
          "ipv4": {
            "type": ["string", "null"],
            "description": "Public IPv4 address, or null when the server has none."
          },
          "ipv6": {
            "type": ["string", "null"],
            "description": "Public IPv6 address, or null when the server has none."
          },
          "private_ip": {
            "type": ["string", "null"],
            "description": "Address in the group's private network, or null when the server is not attached to one."
          },
          "location": {
            "type": ["string", "null"],
            "description": "Hetzner Cloud location the server runs in, such as `nbg1`."
          },
          "server_type": {
            "type": ["string", "null"],
            "description": "Hetzner Cloud server type the server was created with, such as `cx23`."
          },
          "release": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Release"
              },
              {
                "type": "null"
              }
            ],
            "description": "The release the node runs, or null for a server that names none, such as a promoted seed."
          },
          "build_id": {
            "type": ["string", "null"],
            "description": "The build id of the release the node runs, or null when that release has none."
          },
          "phase_entered_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the node entered its current phase."
          },
          "older": {
            "type": "boolean",
            "description": "True when the node runs another release than the group's current one, none, a template version that differs in what changes a running server, or no placement group while the group spreads its servers across hosts. A rollout replaces it, except while the load balancer no longer lists it as a target."
          }
        }
      },
      "Release": {
        "type": "object",
        "required": ["id", "ref", "build_id", "source", "created_at"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Release identifier, a ULID prefixed with `rel_`."
          },
          "version": {
            "type": "integer",
            "description": "The release's number within its group, v1 the first."
          },
          "ref": {
            "type": "string",
            "description": "The artefact the release boots from: a Hetzner snapshot id, an image digest or a Coolify deployment reference."
          },
          "build_id": {
            "type": ["string", "null"],
            "description": "The customer's own build identifier for the release, or null when none was given."
          },
          "source": {
            "type": "string",
            "enum": ["snapshot", "image", "coolify", "api"],
            "description": "How the release was created: a snapshot of the seed, an image digest, a Coolify deployment or the releases endpoint."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the release was recorded."
          }
        }
      },
      "Error": {
        "type": "object",
        "required": ["message", "code"],
        "properties": {
          "message": {
            "type": "string",
            "description": "Human-readable description of the error."
          },
          "code": {
            "type": "string",
            "description": "Stable machine-readable reason: `unauthenticated`, `forbidden`, `not_found`, `method_not_allowed`, `validation_failed`, `rate_limited`, `idempotency_key_invalid`, `idempotency_key_reused`, `idempotency_key_in_flight`, `provider_unavailable`, `key_service_unavailable`, `secrets_unavailable`, `provider_error`, `plan_required`, `precondition_failed`, or the reason an operation changed nothing, such as `stale_generation` or `image_not_found`."
          },
          "errors": {
            "type": "object",
            "description": "Validation messages keyed by field name; present on 422 responses only.",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          "node": {
            "$ref": "#/components/schemas/NodeState",
            "description": "The node as it stands; present when a node report changed nothing."
          }
        }
      },
      "NodeReport": {
        "type": "object",
        "required": ["release", "generation"],
        "properties": {
          "release": {
            "type": "string",
            "pattern": "^[Rr][Ee][Ll]_[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$",
            "description": "The release the pipeline deployed, as `node.created` sent it."
          },
          "generation": {
            "type": "integer",
            "minimum": 1,
            "description": "The group generation the node was created for, as `node.created` sent it."
          }
        }
      },
      "NodeState": {
        "type": "object",
        "required": ["id", "phase", "generation", "release", "ready_reported_at", "failed_reported_at"],
        "properties": {
          "id": {
            "type": "string",
            "description": "Node identifier, a ULID prefixed with `node_`."
          },
          "phase": {
            "type": "string",
            "description": "Current phase in the node lifecycle, as in `Node`."
          },
          "generation": {
            "type": "integer",
            "description": "The group generation the node was created for."
          },
          "release": {
            "type": ["string", "null"],
            "description": "Identifier of the release the node boots from, prefixed with `rel_`."
          },
          "ready_reported_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "When the first ready report was taken, or null."
          },
          "failed_reported_at": {
            "type": ["string", "null"],
            "format": "date-time",
            "description": "When the first failure report was taken, or null."
          }
        }
      }
    }
  }
}
