# Services API

Services. 23 operations under /api/v1. Errors are RFC 9457 problem+json with a stable `code` field. The complete schemas are in https://nebulactrl.com/api/v1/openapi.json.

## GET /environments/{environmentId}/services

List an environment's services

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `environmentId` (path, string, required)
- `cursor` (query, string): Opaque cursor from a previous page's nextCursor
- `limit` (query, integer (int64)): Page size (default 50, max 200)

Response 200: OK (PageServiceView)
  - `items` (ServiceView[] | null, required)
  - `nextCursor` (string): Present when more items exist
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /environments/{environmentId}/services

Create a service

Creating a service stages its configuration; nothing runs until it is deployed. Managed databases are created with `POST /environments/{environmentId}/databases`. A Compose stack is `kind: stack`, `sourceType: compose` with `config.stack.compose`; the server derives `config.stack.components`. 422 `invalid_compose` (details.violations) when the compose file does not parse, uses something stacks do not support, or breaks the safe allow-list while the service's server does not have Unrestricted Compose.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `environmentId` (path, string, required)

Request body (application/json, required): CreateServiceInputBody
  - `config` (ConfigInput)
  - `gitToken` (string): Write-only access token for private HTTPS repositories
  - `kind` (enum: app | worker | database | stack)
  - `name` (string, required)
  - `registryAuth` (RegistryAuthInput)
  - `serverId` (string)
  - `sourceType` (enum: image | git | compose)

Response 201: Created (ServiceView)
  - `appliedGeneration` (integer (int64), required)
  - `config` (Config, required)
  - `createdAt` (string (date-time), required)
  - `currentDeploymentId` (string, required)
  - `currentRelease` (ReleaseRef, required)
  - `desiredState` (enum: running | stopped, required)
  - `domains` (DomainView[] | null, required)
  - `environmentId` (string, required)
  - `generation` (integer (int64), required)
  - `git` (GitRef, required)
  - `gitTokenSet` (boolean, required)
  - `hasStagedChanges` (boolean, required)
  - `id` (string, required)
  - `kind` (enum: app | worker | database | route | stack, required)
  - `latestDeployment` (DeploymentRef, required)
  - `maintenance` (MaintenanceView, required)
  - `name` (string, required)
  - `networks` (NetworkRef[] | null, required)
  - `observed` (ObservedView, required)
  - `observedAt` (string (date-time) | null, required)
  - `pendingChangeCounts` (PendingChangeCounts, required)
  - `privateHost` (string, required)
  - `projectId` (string, required)
  - `registryAuthSet` (boolean, required)
  - `scaling` (ScalingView, required)
  - `server` (ServerRef, required)
  - `serverId` (string, required)
  - `slug` (string, required)
  - `sourceType` (enum: image | git | external | compose, required)
  - `stagedKind` (string | null, required): new | edited | null
  - `stale` (boolean, required)
  - `tailnet` (TailnetView, required)
  - `updatedAt` (string (date-time), required)
  - `volumes` (VolumeRef[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity (`invalid_compose`); 500 Internal Server Error

## GET /instance/docker-options

Get the Docker option catalog

What the Docker options accept: capability lists, the curated sysctls, reserved label prefixes, stop signals and which options work only on an unrestricted server.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Response 200: OK (DockerOptionsCatalog)
  - `allowedSysctls` (string[] | null, required): Container sysctls (IPC and network namespaced) a service may set
  - `gpuCapabilities` (string[] | null, required)
  - `hostSysctlKeys` (string[] | null, required): Host kernel parameters a service may declare as requirements (hostSysctls)
  - `knownCapabilities` (string[] | null, required): Capability names capDrop accepts besides ALL
  - `reservedLabels` (string[] | null, required): Label prefixes services cannot set
  - `safeCapabilities` (string[] | null, required)
  - `stopSignals` (string[] | null, required)
  - `unrestrictedCapabilities` (string[] | null, required): Capabilities that need an unrestricted server
  - `unrestrictedFields` (string[] | null, required): Options that work only on an unrestricted server
  - `warnCapabilities` (string[] | null, required): Safe-listed capabilities the UI warns about
Errors: 401 Unauthorized; 403 Forbidden; 500 Internal Server Error

## GET /services/{serviceId}

Get a service

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (ServiceView)
  - `appliedGeneration` (integer (int64), required)
  - `config` (Config, required)
  - `createdAt` (string (date-time), required)
  - `currentDeploymentId` (string, required)
  - `currentRelease` (ReleaseRef, required)
  - `desiredState` (enum: running | stopped, required)
  - `domains` (DomainView[] | null, required)
  - `environmentId` (string, required)
  - `generation` (integer (int64), required)
  - `git` (GitRef, required)
  - `gitTokenSet` (boolean, required)
  - `hasStagedChanges` (boolean, required)
  - `id` (string, required)
  - `kind` (enum: app | worker | database | route | stack, required)
  - `latestDeployment` (DeploymentRef, required)
  - `maintenance` (MaintenanceView, required)
  - `name` (string, required)
  - `networks` (NetworkRef[] | null, required)
  - `observed` (ObservedView, required)
  - `observedAt` (string (date-time) | null, required)
  - `pendingChangeCounts` (PendingChangeCounts, required)
  - `privateHost` (string, required)
  - `projectId` (string, required)
  - `registryAuthSet` (boolean, required)
  - `scaling` (ScalingView, required)
  - `server` (ServerRef, required)
  - `serverId` (string, required)
  - `slug` (string, required)
  - `sourceType` (enum: image | git | external | compose, required)
  - `stagedKind` (string | null, required): new | edited | null
  - `stale` (boolean, required)
  - `tailnet` (TailnetView, required)
  - `updatedAt` (string (date-time), required)
  - `volumes` (VolumeRef[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## PATCH /services/{serviceId}

Update a service's configuration (staged until deployed)

Requires `If-Match: "<generation>"`. A stale value returns 412 `generation_conflict`. Every effective change increments the generation; the change takes effect on the next deployment.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)
- `If-Match` (header, string): The ETag (generation) from the last GET, e.g. "3". Required.

Request body (application/json, required): UpdateServiceInputBody
  - `config` (ConfigInput)
  - `gitToken` (string): Write-only; an empty string clears the token
  - `name` (string)
  - `registryAuth` (RegistryAuthInput)
  - `serverId` (string)

Response 200: OK (ServiceView)
  - `appliedGeneration` (integer (int64), required)
  - `config` (Config, required)
  - `createdAt` (string (date-time), required)
  - `currentDeploymentId` (string, required)
  - `currentRelease` (ReleaseRef, required)
  - `desiredState` (enum: running | stopped, required)
  - `domains` (DomainView[] | null, required)
  - `environmentId` (string, required)
  - `generation` (integer (int64), required)
  - `git` (GitRef, required)
  - `gitTokenSet` (boolean, required)
  - `hasStagedChanges` (boolean, required)
  - `id` (string, required)
  - `kind` (enum: app | worker | database | route | stack, required)
  - `latestDeployment` (DeploymentRef, required)
  - `maintenance` (MaintenanceView, required)
  - `name` (string, required)
  - `networks` (NetworkRef[] | null, required)
  - `observed` (ObservedView, required)
  - `observedAt` (string (date-time) | null, required)
  - `pendingChangeCounts` (PendingChangeCounts, required)
  - `privateHost` (string, required)
  - `projectId` (string, required)
  - `registryAuthSet` (boolean, required)
  - `scaling` (ScalingView, required)
  - `server` (ServerRef, required)
  - `serverId` (string, required)
  - `slug` (string, required)
  - `sourceType` (enum: image | git | external | compose, required)
  - `stagedKind` (string | null, required): new | edited | null
  - `stale` (boolean, required)
  - `tailnet` (TailnetView, required)
  - `updatedAt` (string (date-time), required)
  - `volumes` (VolumeRef[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 412 Precondition Failed (`generation_conflict`); 422 Unprocessable Entity; 428 Precondition Required; 500 Internal Server Error

## DELETE /services/{serviceId}

Delete a service

Requires `confirm=<slug>`. A deployed service's container is removed from its server; volumes are kept unless `deleteVolumes=true`.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)
- `confirm` (query, string): Must equal the service slug
- `deleteVolumes` (query, boolean): Also remove the service's Docker volumes (data is lost)

Response 204: No Content
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/clone

Clone a service

Creates a staged copy of a service in the same or another environment of the team; nothing runs until it is deployed. The clone gets the source's configuration (Docker options included), variables, volume definitions and, in the same environment, its outgoing links. Secrets are copied sealed and generated values are generated again. Volumes are new and empty (the data is not copied). A managed database is created new and empty with new credentials: restore a dump into it. Domains are dropped (default) or cloned with a suffix; 409 `domain_conflict` when a suffixed hostname is taken. Needs write access to the source and the target environment. Route services, Compose stacks and read replicas are not cloned (422 `not_applicable`). `dryRun` returns the plan and changes nothing; the response lists variable references that do not resolve in the target environment.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serviceId` (path, string, required)

Request body (application/json, required): CloneServiceInputBody
  - `domainSuffix` (string): Default: -copy in the same environment, -<environment slug> in another one
  - `domains` (enum: drop | suffix): drop (default) clones no domain; suffix clones each public domain with domainSuffix added to its first label
  - `dryRun` (boolean): Report what the clone would do and change nothing
  - `environmentId` (string): Environment of the clone (default: the source's environment)
  - `name` (string): Name of the clone (default: the source's name plus ' copy' in the same environment)
  - `serverId` (string): Server of the clone (default: the source's server)

Response 200: OK (CloneResult)
  - `domains` (CloneDomainOutcome[] | null, required)
  - `dryRun` (boolean, required)
  - `environment` (CloneEnvironmentRef)
  - `links` (integer (int64), required)
  - `references` (ReferenceFinding[] | null, required)
  - `service` (ServiceView)
  - `services` (ClonedService[] | null, required)
  - `sharedVariables` (integer (int64), required)
  - `skipped` (CloneSkipped[] | null, required)
  - `warnings` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`domain_conflict`); 422 Unprocessable Entity (`not_applicable`); 500 Internal Server Error

## GET /services/{serviceId}/deploy-hook

Get a service's deploy hook

Whether the hook is on, when its secret was created, and what the last call did. The secret and the URL are never returned here; `urlPattern` shows the URL with a placeholder.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (DeployHookView)
  - `enabled` (boolean, required)
  - `image` (string, required)
  - `isProduction` (boolean, required)
  - `lastCalledAt` (string (date-time) | null, required)
  - `lastOutcome` (enum:  | deploying | queued | ignored, required)
  - `queued` (boolean, required)
  - `rateLimit` (string, required)
  - `secretSetAt` (string (date-time) | null, required)
  - `serviceId` (string, required)
  - `url` (string): The complete hook URL, shown once when the hook is enabled or rotated; never returned again
  - `urlPattern` (string, required): The hook URL with <secret> in place of the secret
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /services/{serviceId}/deploy-hook

Turn a service's deploy hook on or off

Turning the hook on creates a secret (at least 256 bits, stored only as a hash) and returns the complete `url` once; `POST /hooks/deploy/<serviceId>/<secret>` then deploys the service like a manual deploy, with trigger `deploy_hook`. Turning it off deletes the secret, so enabling again gives a new URL. Needs `deployments:write`.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): DeployHookSetting
  - `enabled` (boolean, required): true turns the hook on (new secret, URL returned once); false turns it off and deletes the secret

Response 200: OK (DeployHookView)
  - `enabled` (boolean, required)
  - `image` (string, required)
  - `isProduction` (boolean, required)
  - `lastCalledAt` (string (date-time) | null, required)
  - `lastOutcome` (enum:  | deploying | queued | ignored, required)
  - `queued` (boolean, required)
  - `rateLimit` (string, required)
  - `secretSetAt` (string (date-time) | null, required)
  - `serviceId` (string, required)
  - `url` (string): The complete hook URL, shown once when the hook is enabled or rotated; never returned again
  - `urlPattern` (string, required): The hook URL with <secret> in place of the secret
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/deploy-hook/rotate

Rotate a service's deploy hook secret

Replaces the secret of an enabled hook; the old URL stops working at once. The new `url` is returned once. 409 `deploy_hook_disabled` when the hook is off. Needs `deployments:write`.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (DeployHookView)
  - `enabled` (boolean, required)
  - `image` (string, required)
  - `isProduction` (boolean, required)
  - `lastCalledAt` (string (date-time) | null, required)
  - `lastOutcome` (enum:  | deploying | queued | ignored, required)
  - `queued` (boolean, required)
  - `rateLimit` (string, required)
  - `secretSetAt` (string (date-time) | null, required)
  - `serviceId` (string, required)
  - `url` (string): The complete hook URL, shown once when the hook is enabled or rotated; never returned again
  - `urlPattern` (string, required): The hook URL with <secret> in place of the secret
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`deploy_hook_disabled`); 422 Unprocessable Entity; 500 Internal Server Error

## GET /services/{serviceId}/git

Get a service's repository link

`linked: false` when the service has no link.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (GitLinkView)
  - `autoDeploy` (boolean, required)
  - `branch` (string, required)
  - `connectionId` (string, required)
  - `connectionName` (string, required)
  - `linked` (boolean, required)
  - `provider` (enum: github | gitlab | gitea | forgejo | bitbucket)
  - `queuedCommit` (string, required)
  - `repo` (string, required)
  - `repoUrl` (string, required)
  - `watchPaths` (string[] | null, required)
  - `webhook` (HookStatusView, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /services/{serviceId}/git

Link a service to a repository and set auto-deploy

Points the service's clone URL and branch at the repository (a staged change when they differ) and stores the auto-deploy settings, which are operational: they are applied immediately, never staged and never rolled back. A push to the branch deploys the service (trigger `git_push`) when a changed file matches the watch paths; a push during a running deployment is queued and the newest one deploys next. Webhooks are registered for GitLab, Gitea and Forgejo; GitHub uses the App's webhook and Bitbucket a webhook you create (see the connection). The `webhook` field reports the state.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): LinkInputBody
  - `autoDeploy` (boolean): Deploy on every push to the branch (default: keep the current setting, true for a new link)
  - `branch` (string): Defaults to the service's branch for the same repository, else the repository's default branch
  - `connectionId` (string, required)
  - `repo` (string, required): owner/name (or a clone URL)
  - `watchPaths` (string[]): Monorepo watch paths: globs relative to the repository root ('apps/api/**', 'packages/*/src', '!**/*.md'); a push only deploys when a changed file matches. Empty watches everything.

Response 200: OK (GitLinkView)
  - `autoDeploy` (boolean, required)
  - `branch` (string, required)
  - `connectionId` (string, required)
  - `connectionName` (string, required)
  - `linked` (boolean, required)
  - `provider` (enum: github | gitlab | gitea | forgejo | bitbucket)
  - `queuedCommit` (string, required)
  - `repo` (string, required)
  - `repoUrl` (string, required)
  - `watchPaths` (string[] | null, required)
  - `webhook` (HookStatusView, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 412 Precondition Failed; 422 Unprocessable Entity; 500 Internal Server Error; 502 Bad Gateway

## DELETE /services/{serviceId}/git

Unlink a service from its repository

Stops auto-deploy. The clone URL and branch stay in the service's config.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 204: No Content
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## GET /services/{serviceId}/incidents

List a service's health incidents

Stretches of time in which the service crashed, was unhealthy or restart-looped, newest first. An open incident has no resolvedAt.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)
- `limit` (query, integer (int64)): Default 50.

Response 200: OK (ListIncidentsOutputBody)
  - `items` (IncidentView[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /services/{serviceId}/maintenance

Switch a service's maintenance mode

While enabled, the proxy answers every domain of the service with HTTP 503, a `Retry-After` header and the maintenance page (the same animated page as the automatic error pages), instead of reaching the service. It takes effect immediately, without a deployment, and keeps working through deployments of the service. It is an operational setting: never staged and never rolled back. A deployed service's server is sent the new route table; an undeployed service applies the setting with its first deployment. Idempotent: repeating the same request changes nothing. Switching off keeps the stored text for the next time. tcp/udp route services cannot be put into maintenance (422 `maintenance_unsupported`).

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serviceId` (path, string, required)

Request body (application/json, required): SetMaintenanceInputBody
  - `enabled` (boolean, required): true puts the service into maintenance, false takes it out
  - `eta` (string (date-time)): When the service is expected back (future). Shown on the page and sent as Retry-After. Only used when enabling.
  - `message` (string): Page message (plain text); empty shows the default copy. Only used when enabling.
  - `title` (string): Page title; empty shows 'Down for maintenance'. Only used when enabling.

Response 200: OK (ServiceView)
  - `appliedGeneration` (integer (int64), required)
  - `config` (Config, required)
  - `createdAt` (string (date-time), required)
  - `currentDeploymentId` (string, required)
  - `currentRelease` (ReleaseRef, required)
  - `desiredState` (enum: running | stopped, required)
  - `domains` (DomainView[] | null, required)
  - `environmentId` (string, required)
  - `generation` (integer (int64), required)
  - `git` (GitRef, required)
  - `gitTokenSet` (boolean, required)
  - `hasStagedChanges` (boolean, required)
  - `id` (string, required)
  - `kind` (enum: app | worker | database | route | stack, required)
  - `latestDeployment` (DeploymentRef, required)
  - `maintenance` (MaintenanceView, required)
  - `name` (string, required)
  - `networks` (NetworkRef[] | null, required)
  - `observed` (ObservedView, required)
  - `observedAt` (string (date-time) | null, required)
  - `pendingChangeCounts` (PendingChangeCounts, required)
  - `privateHost` (string, required)
  - `projectId` (string, required)
  - `registryAuthSet` (boolean, required)
  - `scaling` (ScalingView, required)
  - `server` (ServerRef, required)
  - `serverId` (string, required)
  - `slug` (string, required)
  - `sourceType` (enum: image | git | external | compose, required)
  - `stagedKind` (string | null, required): new | edited | null
  - `stale` (boolean, required)
  - `tailnet` (TailnetView, required)
  - `updatedAt` (string (date-time), required)
  - `volumes` (VolumeRef[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity (`maintenance_unsupported`); 500 Internal Server Error

## GET /services/{serviceId}/maintenance/preview

Preview a service's maintenance page

Returns the self-contained HTML page exactly as the proxy serves it (status 200 here; the proxy answers 503). Optional query parameters preview unsaved text.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)
- `draft` (query, boolean): Preview the title, message and eta below instead of the stored ones (empty values show the defaults)
- `title` (query, string)
- `message` (query, string)
- `eta` (query, string): RFC 3339 time

Response 200: The maintenance page (string)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/move

Move a service to another environment

Stages a move of the service to another environment (of any project of the team): it keeps running where it is until its next deployment, which joins the new environment's network and resolves its variables there. Domains, volumes and data stay with the service. References are checked first: variables of the service that point at services staying behind, and variables of other services that point at the moved one, break. The move is refused with 409 `move_blocked` (details.plan) unless `force` is set; links on the canvas are removed, and the services that referenced the moved one are marked as having staged changes. A service in a replica group or with a cross-server link cannot be moved (409 `move_blocked`); route services and Compose stacks are not movable (422 `not_applicable`). `dryRun` returns the plan and changes nothing. Moving a service out of or into a production environment needs approval for guarded clients.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Request body (application/json, required): MoveServiceInputBody
  - `dryRun` (boolean): Report the plan (references, links, slug) and change nothing
  - `environmentId` (string, required): The environment to move the service to
  - `force` (boolean): Move even though references from or to the service break

Response 200: OK (MoveResult)
  - `applied` (boolean, required)
  - `blocked` (boolean, required)
  - `dryRun` (boolean, required)
  - `from` (CloneEnvironmentRef, required)
  - `incoming` (MoveIncomingRef[] | null, required)
  - `links` (string[] | null, required)
  - `outgoing` (ReferenceFinding[] | null, required)
  - `service` (ServiceView)
  - `slug` (string, required)
  - `slugChanged` (boolean, required)
  - `to` (CloneEnvironmentRef, required)
  - `warnings` (string[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`move_blocked`, `move_blocked`); 422 Unprocessable Entity (`not_applicable`); 500 Internal Server Error

## GET /services/{serviceId}/pending-changes

Staged changes not yet deployed

Field-level differences between the service's configuration and its current release. Secret values are masked.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 200: OK (ChangesOutputBody)
  - `items` (FieldChange[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error

## DELETE /services/{serviceId}/pending-changes/{field}

Discard one staged change

Reverts a single field listed by pending-changes (`config.<path>`, `server`, `variables.<KEY>`, `domains.<host>`, `volumes.<name>`) to the state of the current release and advances the generation. 404 when the field is not pending. Returns the updated service.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)
- `field` (path, string, required): A pending-changes field: config.<path>, server, variables.<KEY>, domains.<host> or volumes.<name>

Response 200: OK (ServiceView)
  - `appliedGeneration` (integer (int64), required)
  - `config` (Config, required)
  - `createdAt` (string (date-time), required)
  - `currentDeploymentId` (string, required)
  - `currentRelease` (ReleaseRef, required)
  - `desiredState` (enum: running | stopped, required)
  - `domains` (DomainView[] | null, required)
  - `environmentId` (string, required)
  - `generation` (integer (int64), required)
  - `git` (GitRef, required)
  - `gitTokenSet` (boolean, required)
  - `hasStagedChanges` (boolean, required)
  - `id` (string, required)
  - `kind` (enum: app | worker | database | route | stack, required)
  - `latestDeployment` (DeploymentRef, required)
  - `maintenance` (MaintenanceView, required)
  - `name` (string, required)
  - `networks` (NetworkRef[] | null, required)
  - `observed` (ObservedView, required)
  - `observedAt` (string (date-time) | null, required)
  - `pendingChangeCounts` (PendingChangeCounts, required)
  - `privateHost` (string, required)
  - `projectId` (string, required)
  - `registryAuthSet` (boolean, required)
  - `scaling` (ScalingView, required)
  - `server` (ServerRef, required)
  - `serverId` (string, required)
  - `slug` (string, required)
  - `sourceType` (enum: image | git | external | compose, required)
  - `stagedKind` (string | null, required): new | edited | null
  - `stale` (boolean, required)
  - `tailnet` (TailnetView, required)
  - `updatedAt` (string (date-time), required)
  - `volumes` (VolumeRef[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/restart

Restart the running container

Queues a restart on the service's server. It completes when the agent acknowledges it.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serviceId` (path, string, required)

Response 202: Accepted (ActionResult)
  - `commandId` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## PUT /services/{serviceId}/scaling

Set a service's replica count and autoscaling

Runs the service on N replicas on its server (1-10) and/or turns CPU autoscaling on. Only stateless services qualify: no volumes, an HTTP port and kind other than database (422 `replicas_require_stateless`). A manual `replicas` value while autoscaling is enabled returns 409 `autoscaling_enabled`. The change applies immediately, without a deployment and without rebuilding: a deployed service is scaled through the agent, an undeployed one only stores the setting. It is never a staged change.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serviceId` (path, string, required)

Request body (application/json, required): SetScalingInputBody
  - `autoscaling` (AutoscalingPatch)
  - `replicas` (integer (int64)): Desired replica count, 1-10. Not allowed while autoscaling is enabled.

Response 200: OK (ServiceView)
  - `appliedGeneration` (integer (int64), required)
  - `config` (Config, required)
  - `createdAt` (string (date-time), required)
  - `currentDeploymentId` (string, required)
  - `currentRelease` (ReleaseRef, required)
  - `desiredState` (enum: running | stopped, required)
  - `domains` (DomainView[] | null, required)
  - `environmentId` (string, required)
  - `generation` (integer (int64), required)
  - `git` (GitRef, required)
  - `gitTokenSet` (boolean, required)
  - `hasStagedChanges` (boolean, required)
  - `id` (string, required)
  - `kind` (enum: app | worker | database | route | stack, required)
  - `latestDeployment` (DeploymentRef, required)
  - `maintenance` (MaintenanceView, required)
  - `name` (string, required)
  - `networks` (NetworkRef[] | null, required)
  - `observed` (ObservedView, required)
  - `observedAt` (string (date-time) | null, required)
  - `pendingChangeCounts` (PendingChangeCounts, required)
  - `privateHost` (string, required)
  - `projectId` (string, required)
  - `registryAuthSet` (boolean, required)
  - `scaling` (ScalingView, required)
  - `server` (ServerRef, required)
  - `serverId` (string, required)
  - `slug` (string, required)
  - `sourceType` (enum: image | git | external | compose, required)
  - `stagedKind` (string | null, required): new | edited | null
  - `stale` (boolean, required)
  - `tailnet` (TailnetView, required)
  - `updatedAt` (string (date-time), required)
  - `volumes` (VolumeRef[] | null, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict (`autoscaling_enabled`); 422 Unprocessable Entity (`replicas_require_stateless`); 500 Internal Server Error

## POST /services/{serviceId}/stop

Stop the running container

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `serviceId` (path, string, required)

Response 202: Accepted (ActionResult)
  - `commandId` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 409 Conflict; 422 Unprocessable Entity; 500 Internal Server Error

## POST /services/{serviceId}/tailnet/reprovision

Re-provision the service's tailnet node

Deletes the service's device from the tailnet and re-creates it with a fresh single-use key and a fresh state volume. Use it when the node needs attention (the device was removed in the admin console or its key was rejected). The device gets a new tailnet IP.

Authentication: session cookie or `Authorization: Bearer nct_…` token.

Parameters:
- `Idempotency-Key` (header, string): Client-chosen key (<=255 chars). A retry with the same key replays the original response for 24 hours.
- `serviceId` (path, string, required)

Response 202: Accepted (ReprovisionResult)
  - `commandId` (string, required)
Errors: 401 Unauthorized; 403 Forbidden; 404 Not Found; 422 Unprocessable Entity; 500 Internal Server Error
