Signing and the path into the catalog
What belongs to a release, sha256 and Ed25519 signature, the policy on the device, channels canary → beta → stable, and who creates releases today.
17 minute readApps SDKUpdated: 10/25/2026
On this page
Before you start
- For
- Developers who want to know what happens to their app between build and display, and operators who manage releases.
- Prerequisite
- none for reading. The calls under
/v1/platform/app-catalog/…and theapp-release …commands require an operator credential with the permissionsplatform.app-catalog.list,import,promote,retire, orupdate.
A release freezes the version, manifest, and bytes of an app and moves through the channels up to stable. This page covers what belongs to a release, how a version enters the catalog as a signed artifact, the rules of the channels, and the calls and commands with which platform operations manages the catalog.
Each version comes into being exactly once: CI builds it from its tag as a signed, immutable artifact <appId>-<version>.szapp with the manifest, the bundles for the display software and the editor, images and texts for the store, and the release notes, and every environment fetches this one artifact from the registry. The same version therefore has the same bytes in every environment.
What belongs to a release
In object storage, a version lives under platform/apps/<appId>/<version>/:
| Location | Content |
|---|---|
bundle.zip |
The artifact unchanged. Its sha256 is the digest of its layer in the registry. |
manifest.json |
The version’s manifest in canonical JSON — the catalog stores it embedded, and its hash is the manifestHash. |
runtime/ |
The unpacked files of the runtime bundle that the player loads. |
authoring/ |
The unpacked authoring bundle with the configuration form for the scene editor, if the app has one. |
store/ |
Icon and images for the store. |
The release additionally carries sizeBytes and sha256 (64 lowercase hexadecimal characters) of the stored bytes and the provenance: source (registry for an artifact from the registry, seed-placeholder for a row without bytes that the seed created without access to the registry), under artifact the hash of the index, the release key whose signature has been verified, the signature itself, and the digest in the registry, plus importedAt and importedById. Manifest and bytes are immutable after fetching, and the only fields that can be changed afterward are the notes. They live under notes per audience, namely customer (“New in this version”), operator, and developer, and per language (de-DE, en-US, …), as the artifact’s index brings them. In addition, every release still has the one older note, releaseNotes (max. 2000 characters): the text for operations in de-DE, otherwise the one for customers.
Devices and clients reach a version through four routes. Manifest and bundle require a device’s certificate or a session, and only the unpacked runtime is free of sign-in. The specification lists the same credentials under security, with the names from the Credential column:
| Route | Credential | Response |
|---|---|---|
GET /v1/apps/{appId}/{version}/manifest.json |
deviceCertificate or sessionJwt |
The stored manifest. Its hash is the ETag. A matching If-None-Match yields 304. |
GET /v1/apps/{appId}/{version}/bundle.zip |
deviceCertificate or sessionJwt |
A redirect to a presigned URL — the bytes do not run through the API. For a version from the registry, it is the artifact. |
GET /v1/apps/{appId}/{version}/runtime/{path} |
none | A file of the unpacked runtime bundle, immutable and cacheable for a year. Through it, a display without an agent imports the entry file index.js and the parts it loads relatively (./chunks/…). A placeholder without bytes goes out with no-cache, since it still gets its bytes. An unknown version or file yields 404. |
GET /v1/apps/{appId}/{version}/authoring/index.mjs |
sessionJwt, apiKey, or oauthAccessToken |
A redirect to a presigned URL, for a version from the registry to the unpacked authoring/index.js. If the app declares no authoring bundle, the route answers with 404. The portal’s scene editor loads the bundle while signed in. |
The two catalogs across all apps
Besides the individual version, the API delivers two overviews of the whole catalog. Both take, for each app, the version that applies in the stable channel, the one that entered it most recently and not the one with the highest number, and break down its manifest: one into widget definitions, the other into resource types. The widget catalog additionally lists the six built-in building blocks (text, image, video, shape, graphic, document). They belong to no app and therefore have the reserved identifier core. What exists only in canary or beta appears in neither, and both answer regardless of what an organization has installed: they describe the offering, not the installed base.
| Route | What the response contains |
|---|---|
GET /v1/widget-catalog |
Under entries, one entry per widget with appId, version, and the complete definition: id, name, description, the configSchema as JSON Schema with field texts and examples (see Configure widgets), size, noResolver, and the preview block with defaultConfig, mockData, thumbnailUrl, variants, and states. The two gate apps without a widget contribute nothing, and the six built-in building blocks are included under appId: core. |
GET /v1/resource-type-catalog |
Under entries, one entry per resource type with appId, version, and the definition: id, slug, name, description, listEndpoint, authoringPath, and the three schemas baseSchema, createSchema, and updateSchema. Only apps with resources in the manifest contribute — among the bundled ones, these are calendar, menu-board, and pharmacies-on-duty. |
Both require a credential but no permission of their own: a valid sign-in suffices, and an API key reaches them regardless of which permissions are in its scope.
curl -sS "$SZENERA_API/v1/widget-catalog" \
-H "Authorization: Bearer $SZENERA_API_KEY"
Both responses are flat: a list in which an entry carries the definition and also says which version of which app it comes from.
{
"entries": [
{
"appId": "pharmacies-on-duty",
"version": "2.0.1",
"definition": { "id": "pharmacies-on-duty-display", "name": "Notdienst-Anzeige", "…": "…" }
}
]
}
Signature: the statement about the version
Signing happens in CI, never on a server: CI signs the statement “this app, this version, this manifest, this index” with the Ed25519 release key of each world (app-release-staging for development and staging, app-release-prod for production). When fetching, the API checks the signature of its own world against that world’s public key. Without a valid signature of its world, no version enters the catalog, and there is no fallback to the sha256 alone. Whoever fetches a release signs nothing.
On the device, remote maintenance checks every artifact before it unpacks anything: the sha256 value of the downloaded archive must match the one on the release, then it checks the signed statement against the anchor app-release-<world> in the image, after that the manifest and every file against the index, and it unpacks only runtime/, safeguarded against paths that lead out of the target folder. If the anchor is missing, it rejects every artifact. An archive without an index is not an artifact, and remote maintenance does not accept it. The signing entry in the manifest changes nothing about this and remains only because it appears in the manifests of published versions.
Fetch a release
A release comes into being by importing an artifact from the registry. CI reports every version it has stored, to staging and production. The API does not list the versions of the registry: whoever fetches a version by hand names its version themselves.
POST /v1/platform/app-catalog/imports with the body { "appId": "…", "version": "…" } fetches the version. Optionally, digest names the digest of the OCI manifest (sha256:…) that the caller expects, and CI always sends it. The API checks manifest and layer in the registry, then the archive: every file against its checksum in the index, the manifest against the schema and its hash, identifier and version, and the signature of its world. Only then does it store the bytes and create the release in the canary channel.
curl -X POST "$SZENERA_API/v1/platform/app-catalog/imports" \
-H "Authorization: Bearer $SZENERA_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "appId": "pharmacies-on-duty", "version": "1.3.0" }'
The response is { "release": …, "created": true } with 201 if the version is new in the catalog. If it was already there with the same artifact, the call answers with 200 and created: false, so the call can be repeated. The import fills a placeholder without bytes that has the same manifest, while the row’s channel and the releases already made into channels remain. It replaces the seed’s note, which only describes the placeholder, with the notes from the index, unless someone has changed it.
In doing so, the import backfills the notes: if a version that it already finds in the catalog lacks an audience or language that its artifact’s index carries, it adds it and records that as notes-updated with its actor. That way, a version that entered the catalog before per-audience notes gets the texts for customers and developers. Existing texts remain, and the import does not touch notes that have been changed once. The API reads the index of a version in the catalog from its own object storage, so the version no longer has to be in the registry for that.
What can fail while fetching names the exact reason in details.reason:
| Code | HTTP | Trigger |
|---|---|---|
AppRelease.RegistryArtifactNotFound |
404 | There is no tag under the version in the registry. |
AppRelease.VersionAlreadyExists |
409 | The version is already here with a different artifact or as a placeholder with a different manifest. A version never gets second bytes. |
AppRelease.RegistryImportFailed |
422 | The artifact failed the check: digest, type, or details of the manifest in the registry, checksums, structure of the archive, the app’s manifest, or the world’s signature. An environment that lacks the public key of its world also answers this way. |
AppRelease.RegistryUnavailable |
503 | The environment has no access to the registry, or the registry does not respond. |
The channels
A new release always starts in canary, and nobody chooses the starting channel. Each promotion moves exactly one step, and a jump is not possible. No single person promotes to stable: one person requests, a second confirms, also when restoring. The version that entered stable most recently is the one that applies there, not the one with the highest number.
| Channel | Label in the operator portal | Meaning |
|---|---|---|
canary |
Canary | Freshly fetched — first trial channel. |
beta |
Beta | Extended trial. |
stable |
Stable | The version recommended for organizations. |
deprecated |
Deprecated | Demoted but still usable. |
retired |
Retired | Out of service. End state with a retirement period. |
| Call | Body | Rule |
|---|---|---|
POST …/releases/{id}/promote |
{ "toChannel": "beta" }, to stable with "reason": "…" |
canary → beta always promotes immediately. To stable, the call promotes immediately if the platform does not require a second person (releaseApproval.apps set to off, the default). If it requires one (stable), the call with reason (10–500 characters) creates a request, response { "approval": … } instead of { "release": … }, and without reason it answers AppRelease.SecondPersonRequired (HTTP 409). At most one request waits per version and operation (ReleaseApproval.AlreadyOpen, HTTP 409), and it is valid for seven days. Everything else: Channel.InvalidTransition (HTTP 409). |
POST …/releases/{id}/restore |
{ "reason": "…" }, required (10–500 characters) |
Returns a version from deprecated, or a superseded one in stable, to stable, immediately or, if the platform requires a second person, as a request (response as with promote). For a restore, the version applies again afterward, even ahead of a higher one, and the one that applied until then moves to deprecated. |
POST /v1/platform/ops/approvals/{id}/approve |
— | Another person (permission platform.ops.release.approve) confirms and executes the operation in the same transaction, response { "approval": … }. The requesting person themselves: ReleaseApproval.SamePerson (HTTP 403). Expired, confirmed, or withdrawn: ReleaseApproval.NotOpen (HTTP 409, details.state). The same route also confirms the requests for device software. |
POST /v1/platform/ops/approvals/{id}/withdraw |
{ "reason": "…" }, the reason is optional |
The requesting person withdraws, and anyone else with the permission to approve rejects with it. The version stays in its channel. |
GET /v1/platform/ops/approvals |
Query state, packageKind, package |
The requests of both families (apps and device software), without state the open ones, newest first. For apps packageKind=app&package=<appId>. |
DELETE …/releases/{id}/artifact |
— | Deletes the bytes of the version (permission platform.app-catalog.delete), the row, manifest, and history remain. A version in canary, beta, or stable, with an installation, a scene pin, or an open request is protected (AppRelease.ArtifactInUse, HTTP 409). A second call has no effect. |
POST …/apps/{appId}/releases/cleanup |
{ "keepNewest": 5, "dryRun": true } |
Cleans up the bytes of all versions of the app beyond the newest keepNewest that can be deleted. Without keepNewest, the platform’s retention.apps.keepNewest applies, and with dryRun the call deletes nothing and names what it would delete. |
POST …/releases/{id}/demote |
{ "toChannel": "deprecated" } |
The only backward step: stable → deprecated. |
POST …/releases/{id}/retire |
{ "reason": "…", "grace": "regular" }, for a vulnerability { "reason": "…", "grace": "security", "securityAdvisory": "…" } |
reason is required (1–500 characters), and grace is the choice of retirement period: regular three months, security one month, immediate none. securityAdvisory (up to 2000 characters) belongs exactly to security and is stored on the version afterward. The successor to which the platform switches after the period is recorded: the lowest stable version above the retired one. A second retirement: AppRelease.AlreadyRetired (HTTP 409). |
POST …/releases/{id}/extend-grace |
{ "graceMonths": 12 } or { "graceEndsAt": "2027-03-31T23:59:59.999Z" } |
Exactly one of the two. The months count from the current end of the period, not from now, and a date lies after the current end. |
POST …/releases/{id}/force-retire |
{ "reason": "…" } |
Sets the end of the period to now. The reason is required (1–500 characters) and appears in the history. |
PATCH …/releases/{id} |
{ "notes": { "customer": { "de-DE": "…", "en-US": "…" } } } or { "releaseNotes": "…" } |
Changes only the notes. A named audience is replaced with all languages, and the others remain. releaseNotes sets the text for operations in de-DE. Locked for retired releases. |
Promoting to beta, promoting or requesting stable, and confirming a request each require a checklist with no blocking items: “New in this version” (notes.customer['de-DE']) is present, and no text for customers contains a semicolon or a blocked word. Otherwise the API answers AppRelease.ReviewBlocked (HTTP 409) and names the blocking items in details.blocks. The checklist does not block restoring and retiring.
The retirement period is a choice, not a keyword in the reason: a reason that contains security shortens nothing. For new installations, a retired release can no longer be chosen immediately. Installations on exactly this version get the successor as their target: if it requires nothing new, the platform adopts it immediately, otherwise the installation moves to Update waiting with the end of the retirement period as the deadline. When the period is over, the platform switches installations and scenes to the successor, even without consent, and notifies the organization. If there is no successor, everything stays on the version, and the version’s history records it (no-successor). With immediate, both happen in the same call. If the successor reaches stable only after the period has ended, the version gets a period of seven days (entry grace-extended with a reason), in which a successor that requires consent waits for the organization. After that, the platform switches.
All calls under /v1/platform/app-catalog/… carry the note “Operators only”. Reading requires platform.app-catalog.list, fetching requires platform.app-catalog.import (also with an operator key, which is how CI reports), promoting, requesting, restoring, and demoting require platform.app-catalog.promote (confirming and rejecting a request require platform.ops.release.approve), deleting and cleaning up require platform.app-catalog.delete, retiring and periods require platform.app-catalog.retire, and notes require platform.app-catalog.update.
The history of an app
Every movement of a release is recorded as an entry in the history of its app: creation, fetching from the registry, every release into a channel, demoting, restoring, retiring, retirement periods, changed notes, and every step of a request to a second person (promotion-requested, promotion-approved, promotion-withdrawn, promotion-expired). If a second person confirmed, that person is the actor, and requestedById names the person who requested. Each entry names the channel from and to, the time, and the actor: a person in operations (operator with identifier), CI (ci), an environment’s seed (seed), the platform itself (auto), the zero hour (zero-hour), or legacy for what happened before the history and is reconstructed from the releases’ timestamps.
| Call | For whom | What it delivers |
|---|---|---|
GET /v1/platform/app-catalog/apps/{appId}/history |
Operations (platform.app-catalog.list) |
every entry, newest first, in pages with limit and cursor |
GET /v1/apps/{appId}/history?locale=en-US |
every signed-in organization | the versions that have reached stable, newest first: identifier (releaseId, the same as on installation, proof of play, and update decision), number, since when it has been open to everyone (stableSince), “New in this version” in the requested language or, failing that, in German (note, null without text), and whether it is retired. For an app that operations has switched off for the organization, the call answers with App.NotFound (HTTP 404). |
As CLI commands
The CLI maps the lifecycle after fetching in nine commands. It does not know fetching itself: the environment syncs with the registry every ten minutes, and CI speeds it up with the call above. The requests to a second person are run by the group approval (list, approve, withdraw).
| Command | Purpose |
|---|---|
app-release list |
The releases of the app catalog, filterable with --app and --channel. |
app-release set-notes |
Change release notes. |
app-release promote |
Promote to beta or stable with --channel. To stable, the command promotes immediately or, if the platform requires it, creates a request to a second person, and --reason is then required. |
app-release restore |
Restore a deprecated or superseded version to stable, --reason is required. Immediately or as a request, depending on the setting. |
app-release demote |
The way back when a stable release turned out not to be stable after all: --channel=deprecated, the only permitted step, from stable. |
app-release retire |
Take a release out of service. --reason and --grace=regular|security|immediate are required, and --advisory carries the security note and belongs exactly to --grace=security. |
app-release grace-period |
Extend the retirement period (--months or --until=<yyyy-mm-dd>) or end it immediately (--immediately with --reason), exactly one of these. |
app-release delete-artifact |
Delete the bytes of a version, --yes is required. |
app-release cleanup |
Clean up the bytes of superseded versions of an app, --dry-run or --yes, plus --keep-newest. |
szenera-ops --profile=dev-operator app-release promote arl_01K… --app=weather --channel=beta
szenera-ops --profile=dev-operator app-release promote arl_01K… --app=weather --channel=stable --reason="Drei Tage in beta ohne Meldung"
szenera-ops --profile=dev-operator approval approve rap_01K…
szenera-ops --profile=dev-operator app-release retire arl_01K… --app=weather --reason="Nachfolger da" --grace=regular
All of them belong to the platform operations tool szenera-ops and require an operator credential. The commands with a release position require --app, while list uses it as an optional filter. The release is the positional argument id (identifier arl_…). The complete register is at Commands for platform operations.
What the organization sees from this
- New stable version without new permissions and without changed terms: Existing installations adopt it automatically, and the earlier decisions in the app’s Update tab show the entry adopted silently.
- New stable version with new permissions or changed terms: The installation moves to Update waiting, and the app’s widgets cannot be inserted in the scene editor until the organization accepts in the Update tab (see Data access, permissions, and terms).
- Publishing a scene: If a widget pins a version that does not exist, that is retired and whose retirement period is over, or if it belongs to an app that the organization has not installed, the platform rejects publishing and states the reason in one sentence. A retired version within an ongoing period is a notice.
- Retired release: No longer selectable for new installations. Whoever has installed exactly this version gets the successor, the lowest stable version above it. If it requires nothing new and the organization has not declined it, the platform adopts it immediately. Otherwise the installation moves to Update waiting, with the end of the retirement period as the deadline, and after that the platform switches. Without a successor, the app keeps running unchanged until the end of the period.
What does not exist today
The catalog is purely curated: CI builds and signs every version, and platform operations fetches it into the catalog and steers its lifecycle. There is no developer submission, no review queue, and no statement about review times or criteria. The 26 bundled apps are created in the platform’s source tree, built from their tag, signed as an artifact, and fetched into every environment. Anyone preparing an app does so with platform operations, and the pages of this area describe the contract it is written against.
Related
- What is an app, and how does it get onto a display? — the lifecycle in a picture.
- The manifest —
signingand the other fields. - Commands for platform operations —
app-releaseandapp-statusin theszenera-opstool.