Skip to content

Data access, permissions, and terms

dataAccess (reads, external, retention), permissions for team and operator, unlocks and connects, the legal block: what an app discloses before installation.

18 minute readApps SDKUpdated: 10/25/2026

On this page

Before you start

For
Developers who describe an app so that the installation dialog gives the right disclosure.
Prerequisite
a manifest according to The manifest and the @szenera/apps-sdk SDK from the platform’s source tree (not publicly released). The two queries at the end need only a credential from your organization.

Four blocks of the manifest disclose what an app does with data, who in the team may operate it, what it unlocks in the portal, and which terms an organization agrees to: dataAccess, permissions, unlocks with connects, and legal. For each block, the tables give the format, limits, and the effect in the portal.

Four blocks, four questions

Block The organization’s question Where the portal shows it
dataAccess What does the app do with my data, before and after installation? The Data & access section of the store page and the installation dialog
permissions Who in my team may operate the app? Roles & permissions — collapsed at the end of the store page, spelled out in the installation dialog
unlocks, connects What does the app unlock in the portal, and which external account does it need? Unlocks in the portal
legal Which terms do I agree to? Important note in the installation dialog, store page, footer of the widget

The blocks are deliberately separate: permissions describes who in the team may operate the app, not what the app accesses. The portal tells the organization this at the permissions of the app: “They control who in your team may operate the app.” What the app accesses is shown separately under Data & access.

dataAccess

If the block is missing, the most restrictive reading applies: the app reads nothing, fetches nothing, and keeps its own data on uninstalling. It is therefore needed only where there is something to say.

Field Format Meaning Label in the portal
reads List of tenant-profile, tenant-branding, media-library, team, player-location, fleet-locations, location-hours, default empty Existing data sets of the organization that the app reads. Reads existing data — per data set Organization details, Organization logo, Media library, Team and accounts, Display locations, All locations with address and position, Opening hours of the locations with public holidays
writes List, max. 8 entries, each entry <dataset>:create-<thing>, ≤ 60 characters, default empty Write capabilities on data sets of other parties. Changing and deleting do not exist, and an app can only contribute. Adds data — “Changing or deleting is impossible. An app can only contribute.”
external List, max. 8 entries with name (1–60), purpose (1–200), and optionally sends (≤ 200) External services the app reaches: whom, for what, and what goes out in the process. External connections — “What is sent: …” or “No data of your organization is sent.”
retention kept or deleted, default kept What happens to the app’s own data on uninstalling. When you uninstall — with the names of the resources: “Locations and schedules are kept. You can have them deleted for good afterwards.” or “Uninstalling deletes ….”, and without resources “This app leaves no data of its own behind.”

A fifth card appears in the portal block but does not come from this block. Under Data & access, the portal lists the manifest’s resources before the four cards, the app’s own data areas that it creates for the organization, labeled Creates for you (see The manifest). The separation is intentional: resources says what is added, and dataAccess says what happens to what already exists. The store entry’s action card counts the areas as Own data.

Two rules connect the block to the code:

  • No access without a declaration. Only if reads names a data set does the platform bind the token APP_TENANT_DATA to the backend (see Backend: resolvers, fetchers, and jobs). If a class obtains the token without the manifest naming a data set, startup aborts with a plain-text message that names the missing line. player-location also binds the token: the location OF the display does arrive in the context of the widget resolver (see below), but a fixed, chosen location arrives through location() of this token. location-hours without player-location or fleet-locations also aborts startup, because opening hours belong to a location that the app would then not see.
  • No invented write capability. Every entry under writes must appear in the platform’s catalog. The catalog is empty today, so the platform rejects every declared write capability, and that is the right disclosure: there are none.

An example from the weather app: it reads the organization details (for the language in which weather conditions and weekdays are labeled) and the display’s location, and it reaches an external service. The place comes from the widget configuration, either a fixed place or the display’s location.

dataAccess: {
  reads: ['tenant-profile', 'player-location'],
  external: [
    {
      name: 'OpenWeather',
      purpose: 'Wir holen Wetterlage und Vorhersage für den gewählten Ort oder den Standort des Displays.',
      sends: 'Nur die auf etwa einen Kilometer gerundeten Koordinaten des gewählten Orts oder des Standorts, an dem das Display steht.',
    },
  ],
},

Anyone who fetches an address that a user entered in the portal describes it as a service with a descriptive name (the RSS app calls it “The entered feed address”, the calendar app “The entered ICS address”) and fetches it in the backend only through APP_GUARDED_FETCH.

The location of the display: player-location

A scene often runs in all branches of an organization. With player-location, a widget shows each display’s own place, such as the weather outside the door or the local time, without anyone maintaining a scene per branch. The location is whatever the organization has stored as the location for the display in the portal.

It does not come through APP_TENANT_DATA but in the widget resolver’s context as ctx.location, because only there is it known which display is being resolved for:

Field Content
id Identifier of the location (loc_…), for example to link it with the app’s data
name Name of the location as the organization maintains it (“Altona branch”)
city, countryCode Place and country code (ISO 3166-1, DE) of the address, each null without an address. Street and postal code are never passed.
latitude, longitude Coordinates of the location, together null without coordinates
timeZone Time zone of the location (Europe/Berlin)
hours only with location-hours, see Opening hours

The coordinates come from geocoding the address and are accurate to the building. Anyone who sends them to an external service rounds them to what the purpose needs and says so in sends and in the terms: the weather app sends two decimal places, about a kilometer, the same cell as its cache key.

A resolver has to distinguish three states:

ctx.location When
absent The app does not name player-location under reads, or the organization has not yet consented to the area with the installed version. Only those who read get anything.
null The app may read, but there is no location: no display is involved (preview in the editor without a chosen display) or the display is not attached to any location.
Object Display with a location, even without an address and without coordinates. Anyone who looks up a place checks latitude and treats a location without coordinates like null.

If player-location is added with a new version, the version requires the organization’s consent like any new data access (Update waiting). Until it is accepted, ctx.location is absent.

For null, the widget needs a way out, such as a fallback place from its configuration or a placeholder with a notice, never an empty widget. The weather app uses the fallback place for a location without coordinates as well.

A fixed, chosen location of the organization itself, such as a substitute location for displays without a location, is read by the backend with APP_TENANT_DATA.location(tenantId, locationId) in the same shape. An identifier from another organization yields null. The preview in the editor resolves for a specific display when it sends playerId along (POST /v1/widget-data/preview). In tests, mockWidgetResolverContext({ location: mockWidgetLocation() }) from @szenera/apps-sdk/testing sets a location, and { location: null } sets the empty case.

Opening hours: location-hours

Opening hours are master data of the location. The organization maintains them in the portal at the location (tab Opening hours), through PATCH /v1/locations/{id}, or with the MCP tool set_location_opening_hours, and every app that shows them reads the same ones. The area is separate from player-location because opening hours are not a place specification: an app that needs only the place does not get them, and a consent of its own stays understandable.

With location-hours, every location that the app reads (ctx.location and location(), and with fleet-locations also locations()) additionally carries hours:

Field Content
region Federal state per ISO 3166-2 (DE-HH) or null. Without a federal state, there are no public holidays.
weekly Weekly hours, for each weekday (mon … sun) the windows { opens, closes }. If closes is not after opens, the window ends the next day, and identical times mean 24 hours.
specialDays the special days that touch the period, { startDate, endDate, label, closed, windows }
holidays the statutory public holidays in the period, { date, id, name }. id is stable (easter-monday) so that the display software can name the holiday in its language
from, until the period: 21 days from today in the location’s time zone

hours is absent without the area or without consent, and null means the app may read, but no hours are maintained at the location. What applies on a given day is computed by openingDayOf from @szenera/contracts/fleet with the precedence special day over public holiday (closed) over weekly hours. If the organization changes the hours, the federal state, or the time zone, the platform rebuilds the location’s displays. The app itself handles the change of day and the end of a window through the validUntil of its resolver.

dataAccess: {
  reads: ['player-location', 'location-hours'],
},

In tests, mockWidgetLocation({ hours: … }) sets the hours and mockWidgetLocation({ hours: null }) the location without hours.

All locations: fleet-locations

A fetch that must know in advance for which places it retrieves data reads the directory of locations: APP_TENANT_DATA.locations(tenantId) returns all of the organization’s locations ordered by name, with the full address (street, postalCode, city, countryCode), coordinates, and time zone, and with location-hours including hours. A resolver may not reach outside, so a job needs the places in advance, the Pharmacies on duty app the postal codes, for example. Anyone who sends the address to an external service states in dataAccess.external exactly what goes out.

In tests, createMockAppContainer({ locationsMock: [...] }) supplies the locations for location() and locations().

permissions

The installation enters every permission into the organization’s permission catalog. Two lists, the same shape:

List Who holds it defaultRoles
tenant The organization’s team Roles that receive the permission with the installation, e.g., ['OWNER', 'MANAGER', 'MEMBER']
platform Platform operations empty ([]) — no team member receives the permission

Each entry is a quartet:

Field Format Meaning
key <ns>.<verb> or <ns>.<resource>.<verb> (^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$), e.g., pharmacies-on-duty.location.read The key that the backend checks through APP_PERMISSION_GUARD. The platform prefixes it with apps:<id>:.
label Text, at least 1 character Label in the permission catalog and in the installation dialog.
description Text, at least 1 character One sentence on what the permission allows.
defaultRoles List of roles Starting assignment. The organization can change it in the team area.

The helper definePermission(key) only checks the format of a key and returns it unchanged. The bundled apps write the quartets directly into the manifest.

New permissions in a new version are not adopted silently by the platform: as soon as the version is published as stable, the installation moves to Update waiting, the app’s widgets cannot be inserted in the scene editor, and the app’s Update tab shows the added permissions with Accept and Decline. A call that uses the app beforehand fails with Grant.PendingReacceptance (HTTP 409), and a consent to a different manifest version fails with App.ManifestHashMismatch (HTTP 409).

unlocks and connects

An app does not have to bring a surface. unlocks and connects describe the second purpose of an app: not delivering something but unlocking something. The feature lives in the platform, and the app is the gate in front of it.

Field Format Platform catalog Triggers consent?
unlocks max. 8 keys, lowercase with hyphens, ≤ 40 characters video-walls (Video walls), library-places (Places in the library) No — the row gives the organization something, takes nothing from it, and reads nothing from it.
connects max. 4 keys, lowercase with hyphens, ≤ 40 characters currently microsoft-graph The organization makes the connection itself in the portal. Acquiring, renewing, and revoking the authorization are handled once, centrally, in the platform. The app sees neither the authorization nor the access token.

A key that is not in the catalog keeps the app from loading, because otherwise an app could promise a feature or connection with nothing behind it. Both fields are omitted rather than set empty: an empty field would shift the canonical hash of every existing version.

The two gate apps show the patterns: video-wall declares unlocks: ['video-walls'], and onedrive declares unlocks: ['library-places'] and connects: ['microsoft-graph']. The store entry shows the unlocking in the Data & access tab under Unlocks in the portal. An app that only unlocks a core feature of the platform does not appear in the store as an app: video-wall appears there as the tile Core feature · Video wall (coreFeatures of the overview) and is missing from “My apps.”

Field Required Meaning
disclaimerKey yes Key of the version that a consent refers to (<group>.bedingungen.v1). A change to the substance of the notice bumps it (v1 → v2).
disclaimerSummary yes, max. 500 characters Short summary in plain text. The installation dialog (Important note), store entry, and widget footer show it unchanged. It presents the version, and rewording it under the same key does not require consent.
termsPath no Path of the full text on the public website, ≤ 200 characters, lowercase letters, digits, hyphens, and slashes only (/app-bedingungen/<group>). The portal prepends its base and links it as The app’s full terms. Without termsPath, the dialog shows only the short summary.
requireAcceptanceAt default install install: consent at installation and afterward with every new version of the notice. upgrade: consent with every new version of the app, even if the notice is unchanged. never: no consent. The bundled apps carry install.

Apps with the same legal topic point to the same sheet. The bundled apps use seven groups:

Group (termsPath) Apps
/app-bedingungen/fremdinhalte webpage, youtube, stream, embed
/app-bedingungen/eigene-inhalte notice-board, ticker, qr-code, menu-board
/app-bedingungen/wetter weather
/app-bedingungen/nachrichten rss, tagesschau, bbc, bild, ndr, spiegel, deutschlandfunk
/app-bedingungen/verbindungen calendar, onedrive
/app-bedingungen/notdienst pharmacies-on-duty
/app-bedingungen/oeffnungszeiten opening-hours

clock, countdown, countup, world-clock, word-clock, and video-wall remain without legal. The Opening hours app does show the organization’s own entries, but the platform computes the public holidays, and its terms cover that.

A new version of the terms, meaning a different disclaimerKey, makes a release subject to consent: as soon as the new version is published as stable, the installation moves to Update waiting, exactly as with new permissions. Anyone who changes the substance of the notice therefore bumps the key, in the same pass as the short summary, the full text, and the app version. The platform silently adopts a reworded short summary under the same key. A manifest change without a version bump does not reach existing installations.

Query the state of an installation

An organization reads what has become of the manifest, permissions, and terms through two routes for its own installations. Both require a credential of the organization 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. An operator credential does not reach them, because both always answer about exactly one organization and reject a credential without an organization with 403 Permission.Denied.

Route What the response contains
GET /v1/me/apps Under apps, one row per installation: grantId (identifier tag_…), appId, installedVersion, installedAt, plus the presentation fields of the installed version (displayName, tagline, iconUrl, coverUrl, category, tier, bundleSizeBytes), the data areas as resources, and sceneCount, in how many scenes the app is actually used. Three states sit side by side: status (active or pending-reacceptance), platformStatus (active, beta, disabled), and effect with status (active, locked) and reason (ok, pro-required, premium-required, booking-required, exempt). Uninstalled apps are missing from the list, while switched-off apps and apps no longer included in the package remain in it, because they are part of the installed base. Name, tagline, and data areas appear in the language from locale or Accept-Language, where the version carries them.
GET /v1/me/apps/{appId}/installed The page of the installed app: entry describes the installed version, scenes the scenes that use it, and history the installation in two blocks. history.grant carries status, installedVersion, acceptedManifestHash, pendingManifestHash, a decline with version and time (declinedVersion, declinedAt), and the accounts behind installation, consent, and uninstallation. history.acceptanceHistory is the append-only list of every consent, newest first, with each entry having manifestHash, the version with identifier and number (releaseId, version, both null if the version had already left the catalog before the number existed), acceptedVia (manual or auto), reasons, acceptedAt, and the lists permissions and resourceTypes that were consented to at the time. An app without an installation yields 404 App.NotInstalled.

pendingManifestHash is the machine-readable form of Update waiting: if a value is there, a new version requires consent, and status reports pending-reacceptance. Until the decision, the installation keeps running in its current version. GET /v1/me/apps/updates collects the installations that are waiting for it (with state: "pending", and next to them offers of higher versions with state: "offer"). For each app, GET /v1/me/apps/{appId}/update delivers the decision in plain text: version from and to, reasons, new and removed permissions and data accesses, terms before and after, the deadline, and whether declining is still possible. Accepting is done with POST /v1/me/apps/{appId}/update/accept and declining with POST /v1/me/apps/{appId}/update/reject, both with the manifestHash of the version the update leads to and the permission marketplace.app.accept. A decline holds until the next higher version.

curl -sS "$SZENERA_API/v1/me/apps?locale=en-US" \
  -H "Authorization: Bearer $SZENERA_API_KEY"

The consent history is the record against which a changed version can be checked: each entry records which manifest hash was consented to, with which permissions and when, and whether a person did so or the platform adopted it automatically because neither permissions nor terms had moved.

What the organization sees

The installation dialog carries the subtitle “Review what the app does with your data, then confirm the installation.” It shows the Data & access block, below it lists the permissions as Roles & permissions with their count, shows the short summary of the terms under Important note, links the full text as The app’s full terms, and requires the checkbox I have read the permissions and the app’s terms and agree to them. before Install takes effect.

The same information is already in the app’s entry in the store, in the Data & access tab. Whoever is weighing whether they want the app at all should be able to read its terms before the click. The tab names which areas of its own the app creates (from resources), which data sets it reads, what it adds, which connections it keeps to the outside and what is transferred in the process, and what happens to its data on uninstalling. Below that come Privacy with the recipients from dataAccess.external and the short summary of the terms with the link to the full text, which the portal sets from legal.termsPath. What the manifest leaves out, the portal states explicitly (“This app does not access any of your existing data.”) instead of omitting the card.

GET /v1/me/apps/store/{appId} delivers the entry as StoreEntryView, fully decided: the app’s situation for the organization, exactly one action, and the reason with its numbers. The portal forms the sentences for it, and calculation happens only on the server.

The handbook describes the organization’s view of installing, permissions, and consent: Apps in the handbook.