# Commands for platform operations

The commands of the `szenera-ops` tool that run only with a profile of the audience `operator`: an operator account or an operator key `opk_…`. They do not concern customers of the platform and are not part of the `szenera` command line. They are listed here because `whoami`, `device`, `audit`, `player`, `playback`, `permission`, `api-key`, and `invoice` serve both sides, depending on the profile.

> **Type:** reference
> **For:** Anyone who operates the platform: releases device software, handles incidents, and manages queues and organizations
> **Prerequisite:** the platform’s source tree, a profile with `audience: "operator"`, and the `platform.*` permissions named in the “Route and permission” column. The “Key” column says whether an operator key `opk_…` may carry the permission.

The tool lives in the source tree under `tools/ops-cli` and is not shipped. It runs with `pnpm -s szenera-ops <befehl>` and carries all 230 commands of the platform. Profiles, environment variables, output, and exit codes are the same as for `szenera`. In addition, it knows the audience `operator` (profile field `audience`, environment variable `SZENERA_AUDIENCE`), its own profile file `.secrets/szenera-profile.json` in the source tree, and the fallback to the environments in `.secrets/umgebungen.json`. A command that serves only one side rejects the profile of the other before a call goes out.

The tables have the same form as under [Commands for your organization](/cli/commands-organization): command, purpose, route and permission, whether a key may carry the permission, and whether the command changes something. Operator keys are valid for 1 to 365 days (default 90). Which permissions such a key may carry at all is shown by `szenera-ops permission catalog --key-eligible` under an operator profile. They are the read-only operations routes, the device software, and reading ad sales. Everything else stays reserved for people with two-factor sign-in. Of the 230 commands of the tool, 136 are listed under [Commands for your organization](/cli/commands-organization), and they are the commands of `szenera`. The remaining 94 run only under an operator profile and are listed here. 21 of the 136 serve both sides: 14 of them take a route of their own under an operator profile (`whoami`, `device list`, `device get`, `audit list`, `audit get`, `player get-program`, `playback timeline`, `permission catalog`, the four `api-key …` commands, `invoice list`, and `invoice get`), and seven run unchanged (`health`, `health public`, and the five commands of the escape hatch `api …`).

## Health

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `health deep` | A verdict from five sources, the command to run after a deployment. One red source makes the command red, a silent one appears as `unknown` | `GET /health/ready`, `GET /v1/platform/ops/platform-health`, `GET …/ops/jobs/health`, `GET …/ops/cache/health`, `GET …/ops/events/dispatcher/health`, `GET …/ops/audit/health` · `platform.ops.health.read`, `platform.jobs.health.read`, `platform.cache.stats.read`, `platform.events.dispatcher.read`, `platform.audit.health.read` | partly: cache and audit log only with an account | no |

With `--json`, the response carries the verdict under `verdict` (`green` or `yellow`) and, per source, the fields `name`, `status`, and `hint` under `sources`. A red verdict ends with exit 11 instead. The counter-check from outside is `health public`: the same information the status page shows, without a credential and without an operator profile. It answers “What do our customers see right now?” and not “How are things really?”. The command is listed under [Commands for your organization](/cli/commands-organization).

## Fleet devices

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `device list` | The devices of all organizations, including those without an organization. Plus `--tenant=<tnt_…>`, `--all-tenants`, `--silent-only` (silent for over a quarter of an hour) | `GET /v1/platform/ops/devices` · `platform.ops.device.read` | yes | no |
| `device get <dev_…>` | One device in the operations view: organization and serial number | `GET /v1/platform/ops/devices/{deviceId}` · `platform.ops.device.read` | yes | no |
| `device lookup --serial=<text>` | Find a device by its hardware serial number | `GET /v1/platform/ops/devices/lookup` · `platform.ops.device.read` | yes | no |
| `device get-command <dev_…> <cmd_…>` | What became of a command sent to a device. `--wait` waits until the end (exit 11 on `failed`/`timeout`, exit 12 on the time limit) | `GET /v1/platform/ops/devices/{id}/commands/{commandId}` · `platform.ops.device.command-read` | yes | no |
| `device release <dev_…> --reason=reconnect\|factory\|removed --yes` | Release a device. The reason is mandatory. `reconnect` and `removed` leave Wi-Fi and remote access in place. `factory` takes both along. All displays of the device go with it | `POST /v1/platform/ops/devices/{deviceId}/release` · `platform.ops.device.reset` | no | yes |
| `device block <dev_…> --reason=<text> --yes` | Block a device: it loses its organization, all displays go with it, every certificate is revoked, and the API rejects every sign-in with `409 Device.Blocked`. The reason (1 to 500 characters) is mandatory | `POST /v1/platform/ops/devices/{deviceId}/block` · `platform.ops.device.reset` | no | yes |
| `device unblock <dev_…>` | Lift the block. The device is then unpaired and can be paired again | `POST /v1/platform/ops/devices/{deviceId}/unblock` · `platform.ops.device.reset` | no | yes |
| `device check-ota <dev_…>` | Make a device look for a new OS version right away. Response 202, `--wait` additionally needs `platform.ops.device.command-read` | `POST /v1/platform/ops/devices/{id}/ota-check` · `platform.ops.device.ota-check` | yes | yes |
| `device channel --channel=beta\|stable\|canary --device=<dev_…> --yes` | Change the channel of several devices in one call, `--device` may be repeated up to 100 times. Each device gets its own result, and a device that fails does not hold up the others | `POST /v1/platform/ops/devices/channel` · `platform.ops.device.channel` | no | yes |
| `player get-program <ply_…>` | What is assigned to a display, across all organizations, logged | `GET /v1/platform/ops/devices/{playerId}/program` · `platform.ops.device.playback-read` | yes | no |
| `playback timeline <ply_…>` | The resolved playback timeline of a display, across all organizations, logged | `GET /v1/platform/ops/devices/{playerId}/timeline` · `platform.ops.device.playback-read` | yes | no |

Every call of `device get-command` appears in the audit log of the organization concerned. An operator profile cannot use `device send-command` or `device screenshot`: these routes require `fleet.player.command.issue` and reject an operator identity. The tool says so before the call.

## Incidents and maintenance windows

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `incident list` | The incident reports, with `--id=<inc_…>` a single one including its history | `GET /v1/platform/ops/incidents` · `platform.ops.incident.list`, with `--id` `GET …/incidents/{id}` · `platform.ops.incident.read` | no | no |
| `incident create --title=… --severity=sev-1…sev-4 --scope=platform\|tenant\|device-fleet` | Open an incident. `--public` also puts it on the status page, and without the flag it stays internal | `POST /v1/platform/ops/incidents` · `platform.ops.incident.open` | no | yes |
| `incident update <inc_…> --message=…` | Add an entry to the history. `--status` advances the status at the same time, `--public` puts the incident on the status page, `--internal` takes it down | `PATCH /v1/platform/ops/incidents/{id}` · `platform.ops.incident.update` | no | yes |
| `incident resolve <inc_…>` | Close an incident, plus `--postmortem=<url>` | `POST /v1/platform/ops/incidents/{id}/resolve` · `platform.ops.incident.resolve` | no | yes |
| `maintenance-window list` | The planned maintenance windows, with `--id=<mtw_…>` a single one | `GET /v1/platform/ops/maintenance-windows` · `platform.ops.maintenance.list`, with `--id` `platform.ops.maintenance.read` | no | no |
| `maintenance-window create --title=… --start=… --end=… --scope=…` | Plan a maintenance window. `--banner`, `--notify-days=<tage>`, and `--block-mutations`, which halts the organizations’ write calls | `POST /v1/platform/ops/maintenance-windows` · `platform.ops.maintenance.create` | no | yes |
| `maintenance-window update <mtw_…>` | Move or rewrite a window | `PATCH /v1/platform/ops/maintenance-windows/{id}` · `platform.ops.maintenance.update` | no | yes |
| `maintenance-window cancel <mtw_…> --reason=…` | Cancel a window. The reason goes into the notification | `DELETE /v1/platform/ops/maintenance-windows/{id}` · `platform.ops.maintenance.cancel` | no | yes |

## Queues, cache, events, and connections

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `job queues` | The queues with metrics and state, from three sources in one table | `GET …/ops/jobs/queues`, `GET …/ops/jobs/metrics`, `GET …/ops/jobs/health` · `platform.jobs.queues.read`, `platform.jobs.metrics.read`, `platform.jobs.health.read` | yes | no |
| `job pause <name>` | Pause a queue. What is running finishes | `POST /v1/platform/ops/jobs/queues/{queueName}/pause` · `platform.jobs.queues.pause` | no | yes |
| `job resume <name>` | Resume a paused queue | `POST /v1/platform/ops/jobs/queues/{queueName}/resume` · `platform.jobs.queues.resume` | no | yes |
| `job failed` | The jobs that failed for good, with `--not-replayed`, `--queue=<name>`, `--id=<kennung>` | `GET /v1/platform/ops/jobs/dead-letter`, `GET …/dead-letter/{id}` · `platform.jobs.dead-letter.read` | yes | no |
| `job replay --id=<kennung>\|--all --reason=…` | Enqueue a failed job, or many, again. `--max` (default 100) caps the number | `POST …/jobs/dead-letter/{id}/replay`, `POST …/jobs/dead-letter/bulk-replay` · `platform.jobs.replay` | no | yes |
| `job kill <jobId> --queue=<name> --reason=… --yes` | Abort a running job, response 202 | `POST /v1/platform/ops/jobs/{queueName}/{jobId}/force-kill` · `platform.jobs.force-kill` | no | yes |
| `job cleanup --yes` | Clear out old dead-letter entries | `POST /v1/platform/ops/jobs/cleanup/run` · `platform.jobs.cleanup` | no | yes |
| `cache stats` | State, hit rate, and the hot topics, plus `--window=1m\|5m\|1h` | `GET …/ops/cache/health`, `GET …/ops/cache/stats`, `GET …/ops/cache/topics` · `platform.cache.stats.read` | no | no |
| `cache invalidate --topic=<thema>\|--pattern=<glob> --reason=… --yes` | Discard a topic or a pattern | `POST …/ops/cache/invalidate`, `POST …/ops/cache/invalidate-pattern` · `platform.cache.invalidate` | no | yes |
| `event pending` | What has not been delivered yet. `--metrics` appends the numbers and health of the dispatcher | `GET /v1/platform/ops/events/outbox/pending` · `platform.events.outbox.read` | yes | no |
| `event failed` | What was not delivered for good | `GET /v1/platform/ops/events/outbox/dead-letter` · `platform.events.outbox.read` | yes | no |
| `event get <evt_…>` | An event with its payload: exactly what was sent | `GET /v1/platform/ops/events/outbox/{eventId}` · `platform.events.outbox.read` | yes | no |
| `event replay --id=<evt_…>\|--all --reason=…` | Deliver an event, or many, again, plus `--event-type`, `--since`, `--max` | `POST …/events/outbox/{eventId}/replay`, `POST …/events/outbox/replay-bulk` · `platform.events.outbox.replay` | no | yes |
| `connection list` | The open real-time connections: display, portal session, or renderer | `GET /v1/platform/realtime/connections` · `platform.realtime.read` | no | no |
| `connection disconnect <id> --yes` | Disconnect a connection. The client then reconnects | `POST /v1/platform/realtime/connections/{id}/disconnect` · `platform.realtime.disconnect` | no | yes |

The reason for `job replay`, `job kill`, `cache invalidate`, and `event replay` is mandatory and at least ten characters long.

## Error and permission catalog

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `error catalog` | All error codes of the API with HTTP status, plus `--search=<text>` | `GET /v1/platform/ops/errors/catalog` · `platform.errors.catalog.read` | no | no |
| `permission catalog` | The permission catalog. `--key-eligible` shows what an operator key may carry | `GET /v1/permission-catalog`, sign-in only | n/a | no |
| `permission drift` | What the inventory lacks compared with the catalog. Changes nothing | `GET /v1/platform/ops/permissions/catalog-drift` · `platform.permissions.catalog.read` | no | no |
| `permission reload` | Reload the catalog, which is needed when app permissions are added | `POST /v1/platform/ops/permissions/catalog/reload` · `platform.permissions.catalog.reload` | no | yes |
| `permission backfill` | Bring the roles of the organizations up to date with the catalog, plus `--tenant=<tnt_…>`. `--dry-run` only calculates, `--reconcile` also removes. Operator accounts do not need this: their permissions follow the role | `POST …/ops/permissions/roles/backfill` · `platform.permissions.role.backfill` | no | yes |
| `permission flush-cache --yes` | Discard the cache of effective permissions | `POST /v1/platform/ops/permissions/cache/flush` · `platform.permissions.cache.flush` | no | yes |

## Organizations

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `tenant list` | The organizations of the platform (`active`, `frozen`, `suspended`, `deleted`) | `GET /v1/platform/tenants` · `platform.tenant.list` | no | no |
| `tenant get <tnt_…>` | An organization in the operations view | `GET /v1/platform/tenants/{id}` · `platform.tenant.read` | no | no |
| `tenant provision --slug=… --name=… --owner-email=… --address=…` | Organization, contract, owner, and invitation in one go, plus `--licenses`, `--set=<k=v>` | `POST /v1/platform/tenants/provision` · `platform.tenant.provision`, with `--set` `PATCH …/tenants/{tenantId}/feature-entitlements` · `platform.billing.override` | no | yes |
| `tenant overview <tnt_…>` | The operations record: fleet, content, contacts | `GET /v1/platform/tenants/{tenantId}/overview` · `platform.tenant.read` | no | no |
| `tenant suspend <tnt_…> --reason=… --yes` | Suspend an organization, which cascades to its accounts | `POST /v1/platform/tenants/{id}/suspend` · `platform.tenant.suspend` | no | yes |
| `tenant resume <tnt_…>` | Lift a suspension | `POST /v1/platform/tenants/{id}/resume` · `platform.tenant.resume` | no | yes |
| `tenant delete <tnt_…> --slug=… --yes` | Delete an organization. This cannot be undone. The short name is the confirmation. Its devices are released and then show a pairing code | `DELETE /v1/platform/tenants/{id}` · `platform.tenant.delete` | no | yes |
| `tenant entitlements <tnt_…>` | Read contractual capabilities and set them with `--set=<k=v>` (switch `on` or `off`) | `GET …/tenants/{tenantId}/feature-entitlements` · `platform.billing.invoice.read`, `PATCH …` · `platform.billing.override` | no | yes |
| `tenant licensing <tnt_…>` | Contract status: licenses, items, special terms | `GET /v1/platform/tenants/{tenantId}/licensing` · `platform.billing.invoice.read` | no | no |
| `tenant price-override <tnt_…> --sku=<code>` | Set a special term (`--price` or `--discount`, never both) or withdraw it with `--clear` | `PUT …/tenants/{tenantId}/licensing/items/{skuCode}/override` · `platform.billing.override` | no | yes |
| `tenant sso-recovery <tnt_…> --email=… --reason=… --yes` | Issue SSO emergency access for an account | `POST /v1/platform/tenants/{tenantId}/sso-recovery` · `platform.tenant.sso-recovery` | no | yes |
| `self-registration set` | Read self-registration, switch it with `--on`/`--off --reason=…` | `GET`/`PUT /v1/platform/self-registration` · `platform.self-registration.manage` | no | yes |

The invitation address from `tenant provision` appears in plain text in the output (`--json`: `inviteUrl`). It is the only place where this token leaves the API.

## Operator accounts and operator keys

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `operator list` | The operator accounts, with `--role=owner\|manager\|support\|viewer`, `--status`, `--search`, and `--with-permission=<platform.…>` (accounts whose role carries the permission) | `GET /v1/platform/operators` · `platform.operator.list` | no | no |
| `operator get <ops_…>` | An operator account with its role and the level of the role per bundle | `GET /v1/platform/operators/{id}` · `platform.operator.list` | no | no |
| `operator create --email=… --name=… --role=manager\|support\|viewer` | Create an operator account. The role is required, `owner` is not available here. The two-factor setup appears exactly once | `POST /v1/platform/operators` · `platform.operator.create` | no | yes |
| `operator set-role <ops_…> --role=manager\|support\|viewer --yes` | Replace the role. Only the owner, never the own role, never the owner’s. The new permissions apply with the next request of the account | `PATCH /v1/platform/operators/{id}/role` · `platform.operator.create` | no | yes
| `operator transfer-owner <ops_…> --totp-code=<123456>` | Transfer the owner role, step 1: the owner makes the account an offer. The offer is valid for seven days, and a new one replaces the earlier one. `--accept --totp-code=…` (step 2, under the profile of the target) accepts, after which the target is owner and the former owner is manager. `--status` shows the open offer | `POST /v1/platform/operators/{id}/owner-transfer`, `POST …/operators/owner-transfer/accept`, `GET …/operators/owner-transfer` · `platform.operator.create`, on acceptance and status `platform.operator.list` | no | yes |
| `operator suspend <ops_…> --reason=…` | Suspend an operator account | `POST /v1/platform/operators/{id}/suspend` · `platform.operator.suspend` | no | yes |
| `operator reactivate <ops_…>` | Lift the suspension | `POST /v1/platform/operators/{id}/reactivate` · `platform.operator.suspend` | no | yes |
| `operator reset-mfa <ops_…> --reason=… --yes` | Set up two-factor again. The previous authenticator no longer applies | `POST /v1/platform/operators/{id}/mfa-reset` · `platform.operator.mfa-reset` | no | yes |
| `operator resend-onboarding <ops_…>` | Send the setup email again. It is valid for 72 hours | `POST /v1/platform/operators/{id}/onboarding-resend` · `platform.operator.mfa-reset` | no | yes |
| `api-key list` | The operator keys of the company | `GET /v1/platform/api-keys` · `platform.operator.api-key.list` | no | no |
| `api-key issue --name=… --permissions=<a,b,c>` | Issue an operator key `opk_…`. `--days=<1–365>` (default 90), `--list-allowed` shows the permissions that are allowed | `POST /v1/platform/api-keys` · `platform.operator.api-key.create` | no | yes |
| `api-key rotate <oak_…>` | Renew an operator key, plus `--grace-hours=<0–72>`, `--days` | `POST /v1/platform/api-keys/{id}/rotate` · `platform.operator.api-key.create` | no | yes |
| `api-key revoke <oak_…> --yes` | Revoke an operator key, immediately and permanently | `DELETE /v1/platform/api-keys/{id}` · `platform.operator.api-key.revoke` | no | yes |

## Device software

The component is always the first word after the command: `os`, `agent`, `renderer`, `host-webos`, or `host-tizen`. A version is named by the command with the build identifier (twelve hex characters), and `list` shows it.

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `device-software list <component>` | The versions in storage, newest first: display name with world (`0.6.1-prod`), build, state, the channels in which it applies, the number of devices, and the size | `GET /v1/platform/ops/device-software/{component}/builds` · `platform.ops.device-software.read` | yes | no |
| `device-software releases <component>` | What applies in the channels, and the history of releases, with `--limit` and `--cursor` | `GET …/device-software/{component}/releases` · `platform.ops.device-software.read` | yes | no |
| `device-software devices <component>` | The devices that run the component, with channel, version, build, and state relative to the release (`current`, `updating`, `rolled-back`, `outdated`, `no-release`). Plus `--channel`, `--build`, `--state` | `GET …/device-software/{component}/devices` · `platform.ops.device-software.read` | yes | no |
| `device-software activity` | The history across all components: releases, imports, deletions, requests, and the rollbacks that devices reported. Plus `--component=<component>` | `GET …/device-software/activity` · `platform.ops.device-software.read` | yes | no |
| `device-software release <component> <build> --channel=… --yes` | Make a version apply in a channel. `--pull-along=beta,canary` pulls lower channels along in the same step, `--note`, `--security`. If the platform requires a second person (`releaseApproval.deviceSoftware`), a request is created for stable, and `--reason=…` is then required | `POST …/device-software/{component}/releases` · `platform.ops.device-software.release` | no | yes |
| `device-software promote <component> --from=… --to=… --yes` | Release the build that applies today in the lower channel in the higher one, otherwise like `release` | `GET` and `POST …/device-software/{component}/releases` · `platform.ops.device-software.release` | no | yes |
| `device-software delete <component> <build> --yes` | Delete the bytes of a version, the record stays as a tombstone. A version that applies in a channel or that a request points to is protected (`DeviceSoftware.BuildInUse`, exit 6) | `DELETE …/device-software/{component}/builds/{build}` · `platform.ops.device-software.delete` | no | yes |
| `device-software cleanup <component> --dry-run\|--yes` | Delete the versions beyond the newest `--keep-newest=<n>` (without a value `retention.deviceSoftware.keepNewest`) that can be deleted. `--dry-run` only names what would be deleted | `POST …/device-software/{component}/builds/cleanup` · `platform.ops.device-software.delete` | no | yes |
| `device-software nudge <component> <dsr_…>` | Remind the devices of the channel immediately of the applying release | `POST …/device-software/{component}/releases/{id}/nudge` · `platform.ops.device-software.release` | no | yes |
| `device-software image <build>` | The short-lived download address of the SD card image for a SzeneraOS version | `GET …/device-software/os/builds/{build}/image` · `platform.ops.device-software.read` | yes | no |

The tool does not fetch a build: the environment syncs with the registry every ten minutes, and CI speeds it up with `POST …/builds/import-from-registry` (`platform.ops.device-software.import`, with an operator key). `release` and `promote` require `--yes` because a release reaches the devices of the channel.

## Requests to a second person

The same for device software and apps. Whether a release to stable needs a second person is set by the owner with `settings set releaseApproval.deviceSoftware|apps stable`.

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `approval list` | The requests, without `--state` the open ones. Plus `--state=open\|approved\|withdrawn\|expired`, `--kind=device-software\|app`, `--package=<component or app>` | `GET /v1/platform/ops/approvals` · `platform.ops.device-software.read` or `platform.app-catalog.list` | yes | no |
| `approval approve <rap_…>` | Approve a request as the second person and thereby execute it. The person who made the request gets a one-line message and exit 4 (`ReleaseApproval.SamePerson`) | `POST /v1/platform/ops/approvals/{id}/approve` · `platform.ops.release.approve` | no | yes |
| `approval withdraw <rap_…>` | Withdraw a request, or reject it as another person. `--reason=…` is optional | `POST /v1/platform/ops/approvals/{id}/withdraw` · `platform.ops.release.approve` or `platform.ops.device-software.release` or `platform.app-catalog.promote` | no | yes |

## Platform settings

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `settings get` | All platform settings in full, one that is not set with its default | `GET /v1/platform/settings` · `platform.settings.read` | no | no |
| `settings set <setting> <value> --yes` | Change a setting, for example `releaseApproval.apps stable` or `retention.deviceSoftware.keepNewest 3`. The value is checked against the contract before anything goes out | `PATCH /v1/platform/settings` · `platform.settings.manage` (owner only) | no | yes |

The settings are `releaseApproval.deviceSoftware` and `releaseApproval.apps` (`off` or `stable`, default `off`) as well as `retention.deviceSoftware.keepNewest` (1 to 100, default 5) and `retention.deviceSoftware.auto` (default `false`). In addition there are `retention.apps.keepNewest` (1 to 100, default 5), `retention.apps.unreferencedMonths` (1 to 120, default 12), and `retention.apps.auto` (default `false`). Every change appears in the audit log with the old and the new value (`platform.settings.changed`).

## App catalog

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `app-release list` | The releases of the app catalog, with `--app=<app-id>`, `--channel=canary\|beta\|stable\|deprecated\|retired` | `GET /v1/platform/app-catalog/apps`, `GET …/apps/{appId}/releases` · `platform.app-catalog.list` | no | no |
| `app-release set-notes <arl_…> --app=<app-id> --notes=…` | Change the release notes (up to 2000 characters) | `PATCH …/apps/{appId}/releases/{id}` · `platform.app-catalog.update` | no | yes |
| `app-release promote <arl_…> --app=<app-id> --channel=beta\|stable` | Promote to beta or stable, always one step at a time. To stable, the command promotes immediately if the platform does not require a second person (`releaseApproval.apps = off`), otherwise it creates a request, and `--reason=…` (10 to 500 characters) is then mandatory | `POST …/apps/{appId}/releases/{id}/promote` · `platform.app-catalog.promote` | no | yes |
| `app-release restore <arl_…> --app=<app-id> --reason=…` | Roll back an outdated or superseded version to stable. Immediately or, depending on the setting, as a request that a second person confirms with `approval approve`. The version that applied until then goes to deprecated | `POST …/apps/{appId}/releases/{id}/restore` · `platform.app-catalog.promote` | no | yes |
| `app-release demote <arl_…> --app=<app-id> --channel=deprecated` | Demote a release from stable to deprecated, the only step backward | `POST …/apps/{appId}/releases/{id}/demote` · `platform.app-catalog.promote` | no | yes |
| `app-release retire <arl_…> --app=<app-id> --reason=… --grace=regular\|security\|immediate` | Take a release out of service. `--grace` chooses the retirement period and has no default: `regular` three months, `security` one month, in which case the security advisory `--advisory=…` is mandatory for the organizations, and `immediate` no period, so the platform switches at once to the next higher version in stable. For `regular` and `immediate` there is no advisory | `POST …/apps/{appId}/releases/{id}/retire` · `platform.app-catalog.retire` | no | yes |
| `app-release grace-period <arl_…> --app=<app-id>` | Extend the retirement period, by `--months=<n>` from its current end or to the end of a day with `--until=<jjjj-mm-tt>`, or end it at once with `--immediately --reason=…`. Exactly one choice | `POST …/releases/{id}/extend-grace`, `POST …/releases/{id}/force-retire` · `platform.app-catalog.retire` | no | yes |
| `app-release delete-artifact <arl_…> --app=<app-id> --yes` | Delete the bytes of a version, the row, manifest, and history remain. A version in a channel, with an installation, a scene, or an open request is protected (`AppRelease.ArtifactInUse`, exit 6) | `DELETE …/apps/{appId}/releases/{id}/artifact` · `platform.app-catalog.delete` | no | yes |
| `app-release cleanup --app=<app-id> --dry-run\|--yes` | Delete the bytes of all versions beyond the newest `--keep-newest=<n>` (without a value `retention.apps.keepNewest`) that can be deleted. `--dry-run` only names what would be deleted | `POST …/apps/{appId}/releases/cleanup` · `platform.app-catalog.delete` | no | yes |
| `app-status list` | The apps with their operator status, installations, and exceptions | `GET /v1/platform/app-catalog/managed-apps` · `platform.app-catalog.list` | no | no |
| `app-status set <app> --status=active\|beta\|disabled --reason=…` | Set an app platform-wide | `PATCH /v1/platform/app-catalog/managed-apps/{appId}` · `platform.app-catalog.status.manage` | no | yes |
| `app-status override <app>` | Read exceptions per organization, set them with `--tenant` and `--status`, remove them with `--clear` | `GET …/managed-apps/{appId}/overrides` · `platform.app-catalog.list`, `PUT …/overrides/{tenantId}`, `POST …/overrides/{tenantId}/remove` · `platform.app-catalog.status.manage` | no | yes |

Whether a single person promotes to stable is decided by the setting `releaseApproval.apps`. If the platform requires a second person and `POST …/promote` with `stable` names no reason, it answers `AppRelease.SecondPersonRequired` (exit 6). With a reason, a request is created that a second person confirms with `approval approve`, and it expires after seven days. If the checklist of the version carries a block, `promote` (to beta as well as to stable) and the confirmation of a request to stable refuse with `AppRelease.ReviewBlocked` (exit 6), and the command names the blocked items: “New in this version” is missing in the required language, or a customer text carries a semicolon or a blocked word. “New in this version” is created in the review of the version in Szenera Ops, and `set-notes` sets only the text for operations.

## Billing and items

| Command | Purpose | Route and permission | Key | Modifies |
|---|---|---|---|---|
| `subscription set-payment-way <tnt_…> --way=invoice\|stripe\|none` | Set the payment way of a contract | `PUT /v1/platform/tenants/{tenantId}/billing/payment-way` · `platform.billing.override` | no | yes |
| `subscription approve-invoice <tnt_…> --yes` | Approve the purchase on invoice. `--revoke` withdraws the approval | `PUT /v1/platform/tenants/{tenantId}/billing/payment-way` · `platform.billing.override` | no | yes |
| `subscription check-stripe <tnt_…>` | Check the link of a contract to the payment provider. Changes nothing | `GET /v1/platform/tenants/{tenantId}/billing/stripe-link` · `platform.billing.override` | no | no |
| `subscription relink-stripe <tnt_…> --yes` | Link a contract to the payment provider again. If the customer still exists there, nothing happens (`unchanged`) | `POST /v1/platform/tenants/{tenantId}/billing/relink-stripe` · `platform.billing.override` | no | yes |
| `subscription backfill-period-prices --yes` | Catch up the per-period unit prices of the existing contracts: the fix when the run at application startup failed. Idempotent | `POST /v1/platform/billing/period-unit-prices/backfill` · `platform.billing.plan.manage` | no | yes |
| `invoice list --tenant=<tnt_…>\|--all-tenants` | The invoices of all organizations | `GET /v1/platform/invoices` · `platform.billing.invoice.read` | no | no |
| `invoice get <inv_…>` | An invoice with its line items | `GET /v1/platform/invoices/{id}` · `platform.billing.invoice.read` | no | no |
| `invoice mark-paid <inv_…> --yes` | Book a payment received | `POST /v1/platform/invoices/{id}/mark-paid` · `platform.billing.invoice.mark-paid` | no | yes |
| `invoice refund <inv_…> --reason=… --yes` | Refund an invoice | `POST /v1/platform/invoices/{id}/refund` · `platform.billing.invoice.refund` | no | yes |
| `invoice issue-documents <inv_…> --yes` | Generate the PDF and e-invoice of a document after the fact. A document that already carries its files stays untouched | `POST /v1/platform/invoices/{id}/issue-documents` · `platform.billing.invoice.refund` | no | yes |
| `catalog list` | The item catalog with the special terms that are set | `GET /v1/platform/billing/catalog` · `platform.billing.plan.manage`, `GET /v1/platform/billing/overrides` · `platform.billing.override` | no | no |
| `catalog set [sku]` | Replace a catalog item (all five fields) or set the monthly price of an app with `--app=<app-id> --price=<cent>` | `PUT /v1/platform/billing/catalog/{skuCode}`, `PUT /v1/platform/billing/app-prices/{appId}` · `platform.billing.plan.manage` | no | yes |

Under an operator profile, `audit list` and `audit get` also run against `GET /v1/platform/audit` and `GET /v1/platform/audit/{id}` (`platform.audit.read`). `--tenant` or `--all-tenants` is mandatory there.

## Related

- [Commands for your organization](/cli/commands-organization)
- [Profiles and credentials](/cli/profiles-and-credentials)
