# API Howto

## Content

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Display name |
| `comment` | string | Description |
| `structureType` | enum | `SLIDE` (single page) or `COMPLEX` (multi-page) |
| `simpleLoopType` | enum | Playlist: `NORMAL` (default) or `EINK` (rare) |
| `version` | string | Playlist: required `"2.0"`. Content/Slide: `"1.0"` or empty |
| `locked` | boolean | If true, cannot be edited |
| `lastSaved` | datetime | Last save time |
| `lastSavedBy` | object | `.email`, `.name` |
| `lastActivity` | datetime | ⚠️ **DEPRECATED** — frozen at last write before retirement; not maintained going forward. Use proof-of-play for screen-time data |
| `contentGroup` | object | `.id`, `.name` — parent folder |
| `contentGroupPath` | string | Full path, useful for recursive search |
| `assignedDeviceCount` | int | Devices showing this (computed) |
| `previewPath` | string | Preview image URL (computed) |
| `orientation` | string | From dimensions (computed) |
| `data` | JSON | ⚠️ **HUGE** — avoid in SELECT |
| `campaignUsageDetails` | array | Channels using this (SELECT-ONLY) |
| `messageUsageDetails` | array | Sub-channels using this (SELECT-ONLY) |

**Unified read endpoint**: `/api/v2/deviceContent` (all types)
**Type-specific write endpoints**: `/api/content`, `/api/simpleLoop`

| `deviceContentType` | UI Term | Type-specific endpoint |
|---------------------|---------|------------------------|
| `content` | Slide or Content | `/api/content` |
| `simpleLoop` | Playlist | `/api/simpleLoop` |

### Read
```
GET /api/v2/deviceContent?select=id,name,deviceContentType,structureType,contentGroup(id,name)
GET /api/v2/deviceContent?includeLoops=false&includeSchedules=false     # Only slides/content
GET /api/v2/deviceContent?includeContents=false&includeSchedules=false  # Only playlists
GET /api/v2/deviceContent?search=contentGroupId%3D{id}    # In folder
```

| Param | Default | Notes |
|-------|---------|-------|
| `includeContents` | true | Include slides/content |
| `includeLoops` | true | Include playlists |
| `includeSchedules` | true | Set false to exclude old schedule records from content/playlist reads |

**Usage info**:
```
GET /api/{content|simpleLoop}/usageDetails?search=id%3D{id}                 # Embedded in (other content/playlists only)
GET /api/{content|simpleLoop}/getDeviceOnlineOfflineRatio?search=id%3D{id}  # Device stats
GET /api/v2/device?search=contentId%3D{id}|emergencyContentId%3D{id}        # Devices with this assigned
```

**Templates**:
```
GET /api/template?select=id,name,structureType,tags,comment,previewPath&size=20
GET /api/template?search=(tags:healthcare|tags:retail),tags:minimalist  # (industry OR) AND style
```
Use `template_tags` tool to discover available tags.

**Quick-Editable Templates** (for playlist pages):
```
GET /api/contentGroup/?search=isTemplateGroup%3Dtrue&select=id,name
GET /api/v2/deviceContent?search=contentGroupId%3D{folderId},structureType%3DSLIDE&select=id,name,previewPath
GET /api/v2/deviceContent?search=id%3D{slideId}&select=contentMetaData
```
Check `contentMetaData` for widgets with `quickEdit: true` — customizable via `widgetOverrides` in playlist.

If `contentMetaData.quickEditableWidgets.collectionBasedDataBinding` is `true`, the Slide can be added as a Paginated Slide playlist page (`"type": "slideCollection"`): playback expands datasource rows into generated entries. Normal Slide insertion remains available.
`contentMetaData.quickEditableWidgets.collectionRowCount` is the readable generated-entry row count, derived from Default-page widget bindings where `dependsOnBase=true` and `rowOffset` is set (`data.Default.tags[*].dataBinding.boundProperties[*].rowOffset`).
Playlist `widgetOverrides` may replace the concrete datasource for that page; replacement datasource must match the Slide template's bound structure/paths.

### Create

**Content/Slide:**
```
POST /api/content/?contentGroupId={folderId}
Body: {
  "name": "Welcome",
  "structureType": "SLIDE",
  "data": {
    "Default": {
      "tags": {},
      "pageStyle": {},
      "pageData": {"screenSize": {"width": 1920, "height": 1080}}
    }
  }
}
```

**Playlist:**
```
POST /api/simpleLoop/?contentGroupId={folderId}
Body: {
  "name": "My Playlist",
  "simpleLoopType": "NORMAL",
  "version": "2.0",
  "data": {
    "loopData": {
      "orientation": "landscape",
      "resolution": {"width": 1920, "height": 1080},
      "defaultImageFit": "fill",
      "defaultVideoFit": "fill"
    },
    "slides": []
  }
}
```

**From template:**
```
POST /api/content/fromTemplate/{templateId}?contentGroupId={folderId}
Body: {"name": "From Template"}
```

| Param | Where | Notes |
|-------|-------|-------|
| `contentGroupId` | query | Required |
| `name`, `structureType`, `data` | body | Content: all required |
| `name`, `simpleLoopType`, `version`, `data` | body | Playlist: all required |

### Update
```
PUT /api/{content|simpleLoop}/{id}?autoSave=false
Body: {"name": "Updated", "comment": "Description"}
Clear comment: {"resetComment": true}

POST /api/{content|simpleLoop}/lock/{id}/true   # Lock (Approver role)
POST /api/{content|simpleLoop}/lock/{id}/false  # Unlock

POST /api/{content|simpleLoop}/duplicate/{id}   # Duplicate
Body: {"name": "Copy"}
```

**Note:** Content duplicate returns the created entity, SimpleLoop duplicate returns void.

Metadata PUT via `api_write` accepts only `name`, `comment`, and `resetComment` (any subset). `resetComment=true` clears the existing comment; an empty `comment` does not.

**⚠️ NEVER read or edit the `data` field via api_read/api_write.** Full Slide/Content editor changes are not supported through MCP. For Playlist pages/settings use `manage_playlist`.

`autoSave` only matters when updating `data` field. `locked` content cannot be updated.

**Move**: See Folders section → `POST /api/contentGroup/move`

**Tags**:
```
POST /api/deviceContent/{id}/setTags  Body: ["tag1", "tag2"]
```

**Bulk tags** (via v2 API):
```
POST /api/v2/deviceContent/tags/set?search={wbql}     Body: ["tag1", "tag2"]
POST /api/v2/deviceContent/tags/add?search={wbql}     Body: ["tag1"]
POST /api/v2/deviceContent/tags/remove?search={wbql}  Body: ["tag1"]
```

### Delete
```
DELETE /api/{content|simpleLoop}/{id}?autoSave=false
```

## Customer

Customer = tenant in multi-tenant system.

**Access levels:**
- **ADMIN**: Global access to all customers
- **Network Owner** (subreseller): Manages owned customers — **a Network Owner IS itself a customer** with `subreseller` object present
- **Regular user**: Scoped to their own customer

**Cross-reference queries:** Because customers and subresellers share the same table, WBQL can cross-reference them:
```
search=subreseller=!NULL                           # All Network Owners
search=ownerSubresellerId={id}                     # Customers under a Network Owner
search=subreseller.freeLicenses>0                  # Network Owners with free licenses
```

**Entity attributes:**

| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Required, globally unique |
| `comment` | string | Description |
| `country` | string | Country code |
| `location` | string | Physical location |
| `type` | string | Custom classification |
| `vertical` | enum | Industry vertical (see below) |
| `createdDate` | datetime | Creation timestamp |
| `restricted` | boolean | Account disabled — blocks all access |
| `expirationDate` | datetime | Account expiration — triggers device lockout |
| `force2FA` | boolean | Mandatory 2FA for all users |
| `needsToBeInvoiced` | boolean | Invoice tracking (ADMIN only) |

**Vertical values (21):** `BANKING_AND_FINANCE`, `EMPLOYEE_CORPORATE_COMMUNICATION`, `CORPORATE_MEETING_ROOMS`, `DIGITAL_SIGNAGE`, `EDUCATION_COLLAGE_AND_UNIVERSITIES`, `CONFERENCE_AND_CONVENTION_CENTERS`, `GOVERNMENT`, `HEALTHCARE`, `HOSPITALITY`, `QUICK_SERVICE_RESTAURANT`, `RETAIL`, `SPORTS`, `PETROL_STATIONS`, `TRANSPORTATION`, `ENTERTAINMENT`, `WAYFINDING_AND_DIRECTORIES`, `OTHER`, `FACTORY_PRODUCEMENT`, `BUSINESS_PARTNER`, `RESELLER_PARTNER`, `TECHNOLOGY_PARTNER`

**License fields:**

| Field | Type | Notes |
|-------|------|-------|
| `licenseTier` | enum | Package tier: `STARTER`, `BUSINESS`, `ENTERPRISE` |
| `resourceLimits` | object | `{overrides: {RESOURCE_KEY: integer}}` for Enterprise customers; omitted keys inherit the current Business limit (ADMIN only) |
| `freeLicenses` | long | Demo licenses from server's global pool — can only assign if pool has availability (ADMIN only) |
| `browserSessionLicenses` | long | Browser content licenses from global pool — legacy, PWA devices replaced this (ADMIN only) |
| `deviceSessionLimit` | long | Max concurrent device sessions (0=unlimited). Special pricing case: pay for 1000 licenses but limit to 20 concurrent (ADMIN only) |
| `activeLicenses` | long | COUNT of Device records assigned to customer — actual deployed devices (computed) |
| `totalLicenses` | long | SUM of approved LicenseOrder amounts — purchased license capacity (computed) |

**Storage fields:**

| Field | Type | Notes |
|-------|------|-------|
| `storageSize` | long | Allocated quota in bytes, null=unlimited (ADMIN only) |
| `currentTotalCustomerStorageSize` | long | Total used storage (computed) |
| `currentDatasourceStorageSize` | long | Datasource storage (computed) |
| `currentCustomerFilesStorageSize` | long | Uploaded files storage (computed) |

**Team access settings** (requires separate `/settings/teams` endpoint):

| Field | Default | Notes |
|-------|---------|-------|
| `userFullAccessIfNotInTeam` | true | Non-team users: full access vs root only |
| `accessResourcesWithoutTeam` | true | Allow access to team-less resources |
| `isDeviceAndGroupCreationEnabledInRootForTeamUsers` | true | Team users can create devices in root |
| `isContentAndGroupCreationEnabledInRootForTeamUsers` | true | Team users can create content in root |
| `isFileAndFolderCreationEnabledInRootForTeamUsers` | true | Team users can create files in root |

**Network Owner fields:**

| Field | Type | Notes |
|-------|------|-------|
| `ownerSubresellerId` | long | Parent Network Owner ID |
| `ownerSubresellerName` | string | Parent name (computed) |
| `subreseller` | object | Present if this customer IS a Network Owner |

**Other fields:** `hiddenUIElementRules` (JSON), `customerMetadata` (JSON) — read-only for AI. `brandingGuideline` is exposed through the read-only `get_branding` MCP tool. `vistarNetworkId`, `vistarApiKey`

### Read
```
GET /api/customer?search=name:Acme&select=id,name,licenseTier,restricted&size=10
GET /api/config/limits                                   # ADMIN: current and built-in resource limits
GET /api/customer/resourceLimits                         # Effective limit and usage for selected customer
GET /api/customer/{customerId}/license/dashboard    # License dashboard (customer-specific)
GET /api/statistics/network/dashboard?customerId={id}&includeOwnerData=true   # Network Owner: includes owned customers
GET /api/statistics/system/dashboard                # System dashboard (ADMIN only)
```

**ADMIN workflow:** Search customer by name → use `id` as `customerId` in other requests.

**Network Owner:** Sees only customers where `ownerSubresellerId` matches their customer ID.

**License dashboard:** Customer-specific license metrics — `assignedLicenseCount`, `unAssignedLicenseCount`, `deviceStatusCounts`, `licenseTypeCounts`, `browserSessionLimit`, `activeBrowserSessionCount`, `freeCount`, `usedFreeCount`

**System dashboard:** Comprehensive platform metrics — device counts by status (assigned/unassigned), license counts, customer counts by vertical/tier, user counts, content/playlist/schedule counts, datasource counts by type, credential counts, server stats, license/support expiration dates. Network dashboard (`?includeOwnerData=true`) includes owned customers.

**Resource-limit API:** `GET /api/config/limits` returns `{resource,tier,limit,defaultLimit,maxLimit,editable}` rows (ADMIN). Save editable server keys with `PUT /api/config/batch` (`{"configUpdates":[{"name":"config.limit.USER.business","value":"<integer>"}]}`); reset selected keys with `DELETE /api/config/limits/batch?keys={key}`. `GET /api/customer/resourceLimits` returns `{resource,limit,used}` for the caller's selected customer. A zero limit blocks creation; a reached cap returns HTTP 400 with `key="resourceLimit.error.limitReached"` and `details={resource,used,limit}`.

### Branding (MCP read-only)

Use `get_branding`; do not request the full customer authorization response through generic API tools. All Wallboard roles can read accessible-customer Branding. The tool scopes through `GET /api/customer/select`, then returns only `brandingGuideline` data. `customerId` is required for global/multi-customer roles.

| Action | Result |
|--------|--------|
| `list` (default) | Kit IDs, company fields, and element counts |
| `get` + `kitId` | One complete stored kit |

`brandingGuideline.kits[]` fields:

| Field | Type / structure | Notes |
|-------|------------------|-------|
| `id` | string | Client-generated kit ID |
| `companyName` | string | Required by UI |
| `companyUrl`, `description`, `styleDescription` | string | Company and style metadata |
| `colorPalettes` | `[{id,name,colors:[{id,colorCode,custom?,name?}]}]` | Fixed color IDs: `primary`, `secondary`, `accent`, `lightBackground`, `darkBackground` |
| `fonts` | `[{id,fontId,isUploaded?,custom?,name?}]` | Fixed font IDs: `header`, `subheader`, `body` |
| `logos` | `[{id,fileId,custom?,name?}]` | Fixed logo IDs: `main`, `alternative` |
| `rootFolderId` | string? | Deprecated; may exist in older data |

Branding writes are intentionally not exposed through MCP.

### Create
```
POST /api/customer
Body: {"name": "Acme Corp", "licenseTier": "ENTERPRISE", "vertical": "RETAIL"}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | ✓ | Globally unique |
| `licenseTier` | - | Default: `ENTERPRISE` |
| `vertical` | - | Industry vertical |
| `createAsSubreseller` | - | `true` to promote immediately |
| `ownerSubresellerId` | - | Parent Network Owner (ADMIN only) |

**Auto-creates:** Root deviceGroup, root fileFolder, "Templates" contentGroup.

**Individual Enterprise customer overrides:** ADMIN (Super administrator) may set `resourceLimits.overrides` on customer create/update; keys are resource enum names and values are integer caps. Omit a key to inherit the current Business limit. Network Owners cannot create Enterprise customers or change customers to/from Enterprise. Network Owner create requests must explicitly set `licenseTier` to `STARTER` or `BUSINESS` because an omitted value defaults to `ENTERPRISE`.

**Network Owner creating:** Automatically sets `ownerSubresellerId` to caller's customer. Cannot set `freeLicenses`, `browserSessionLicenses`.

### Update
```
PUT /api/customer/{id}
Body: {"name": "New Name", "restricted": false, "expirationDate": 1735689600000}
```

**Team settings** (separate endpoint — fields not on main DTO):
```
PUT /api/customer/settings/teams
Body: {"userFullAccessIfNotInTeam": false, "accessResourcesWithoutTeam": false}
```

**Emergency content** (separate endpoint — field not on main DTO):
```
PUT /api/customer/settings/setDefaultContents?defaultEmergencyContentId={contentId}
PUT /api/customer/settings/setDefaultContents?defaultEmergencyContentId=null   # Remove
```

**Network Owner operations (ADMIN only):**
```
POST /api/customer/{id}/promoteAsSubreseller          # Promote to Network Owner
POST /api/customer/{id}/removeSubresellerPrivilege   # Remove privilege (must have no owned customers)
PUT /api/customer/{id}/moveToSubreseller?subresellerId={id}   # Move under Network Owner
PUT /api/customer/{id}/moveToSubreseller              # Remove from Network Owner (no param)
```

### Delete
```
DELETE /api/customer/{id}
```

**Protected operation** — may require TOTP + CAPTCHA. **Cascades:** Deletes all devices, content, datasources, campaigns.

## Device

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Display name |
| `comment` | string | Description |
| `deviceGroupId` | string | Folder reference |
| `deviceStatus` | enum | `ONLINE`, `OFFLINE` |
| `lastDeviceStatusChange` | datetime | Status change timestamp |
| `emergencyStatus` | boolean | Emergency mode active |
| `tags` | array | Device tags |
| `type` | enum | `TABLET`, `PHONE`, `SCREEN`, `DESKTOP`, `EINK`, `PWA` |
| `platform` | enum | `ANDROID`, `WINDOWS`, `BRIGHTSIGN`, `SAMSUNG`, `LG`, `CHROME_OS`, `BROWSER`, `UNKNOWN` |
| `serial` | string | Serial number |
| `version` | string | Player app version |
| `firmwareVersion` | string | Device firmware |
| `nativeResolutionWidth` | int | Screen width |
| `nativeResolutionHeight` | int | Screen height |
| `localIpAddress` | string | Device IP |
| `nativeOrientation` | string | Physical orientation |
| `webViewOrientation` | string | Content orientation |
| `previewPath` | string | Screenshot URL (computed) |
| `lastActivity` | datetime | Last device contact |
| `licenseStatus` | enum | `UN_LICENSED`, `FREE`, `LICENSED`, `TRIAL` |
| `licenseOrderId` | long | License order reference |
| `licenseType` | enum | `BASIC`, `PROFESSIONAL`, `ENTERPRISE`, `VIDEO_WALL` |
| `timeZone` | string | IANA format |
| `deviceInfo` | JSON | ⚠️ **HUGE** — avoid in SELECT, request only for single device |
| `deviceGroup` | object | `.id`, `.name` — parent folder |
| `content` | object | `.id`, `.name` — assigned content |
| `emergencyContent` | object | `.id`, `.name` — emergency content |
| `alerts` | array | Active alerts |

### Read
```
GET /api/v2/device?search=deviceGroupId%3D{folderId}&select=id,name,deviceStatus,previewPath
GET /api/v2/device?search=deviceGroupPath:{folderId}       # Recursive
GET /api/v2/device?search=tags:lobby                       # By tag
GET /api/v2/device/{id}/previewStore                       # Preview history
```

**Preview URL**: prefer the computed `previewPath` field (request it in `select`) over building the path. It already carries the cache-busting timestamp. The underlying path is `/api/storage/customer/{customerId}/devices/{deviceId}/preview.jpg` — `customer`, singular, as everywhere else under `/api/storage`.

**Admin only** — unassigned devices (not yet linked to customer):
```
GET /api/v2/device?search=customerId%3Dnull&select=id,activationCode,serial,platform
```

### Create

**Two methods:**

1. **Activation Code** — device shows 4-digit code on screen after first boot, user reads it
   - First query unassigned devices: `GET /api/v2/device?search=customerId%3Dnull` (ADMIN only)
   - Then assign using the code from the device

2. **Pre-registration (zero-touch)** — create before physical device exists, auto-links on first boot
   - Use `serial` + `createDeviceIfSerialNotFound: true`
   - Device auto-assigns when it boots with matching serial

```
POST /api/v2/device/assignToCustomer
Body: {"activationCode": "1234", "deviceName": "Lobby", "licenseStatus": "FREE", "licenseType": "BASIC", "timeZone": "Europe/Budapest"}
```

| Field | Required | Notes |
|-------|----------|-------|
| `activationCode` | * | 4-digit code shown on device screen — query unassigned devices first |
| `serial` | * | Serial number — use with `createDeviceIfSerialNotFound` for pre-registration |
| `deviceName` | ✓ | |
| `licenseStatus` | ✓ | `FREE` (+ licenseType), `LICENSED` (+ licenseOrderId), `UN_LICENSED` |
| `licenseType` | | Required if FREE: `BASIC`, `PROFESSIONAL`, `ENTERPRISE` |
| `timeZone` | ✓ | IANA format (e.g., `Europe/Budapest`) |
| `createDeviceIfSerialNotFound` | | `true` for pre-registration |

### Update
```
PUT /api/device/{id}  {"name": "New Name", "comment": "Updated"}
POST /api/v2/device/move  {"deviceIds": [...], "targetGroupId": "target"}
```

**Batch operations** — Pattern: `{POST|DELETE} /api/v2/device/{command}?customerId={id}&search={wbql}&applyOn={DEVICE|DEVICEGROUP|ALL}[&params]`

`applyOn` specifies what `search` targets: `DEVICE` (query matches devices), `DEVICEGROUP` (query matches folders → affects all devices in them), `ALL` (both).

Commands marked with ⊖ also support DELETE (same pattern, no body) to remove/reset.

**Content:**
| Command | Description | Params |
|---------|-------------|--------|
| `assignContent` | Assign content to device | `contentId` (string), `asAssigned` (bool)=true, `asEmergency` (bool)=false |
| `detachContent` | Remove content from device | `detachNext` (bool)=true, `detachAssigned` (bool)=true, `detachEmergency` (bool)=false |
| `refreshContent` | Reload current content | — |
| `cacheContent` | Pre-download content | `contentId` (string), `cacheAt` (datetime)? |
| `clearCache` | Clear device cache | optional JSON body: `{"limit":10,"fileExtensions":["js","css"]}`; limit in MB (0 = no size threshold), filters combine |

**Display:**
| Command | Description | Params |
|---------|-------------|--------|
| `display` | Turn screen on/off | `enabled` (bool) |
| `volume` | Set volume level | `level` (int) 0-100 |
| `brightness` | Set brightness level | `level` (int) 0-100 |
| `rotation` | Rotate display | `angle` (int), `type` (enum): `WEB_VIEW`, `DEVICE` |

**Power:**
| Command | Description | Params |
|---------|-------------|--------|
| `restart` | Restart device | — |
| `rebootTime` ⊖ | Set daily reboot time | `time` (string) HH:mm |
| `workingHours` | Set operating hours | body: `{"mode": "SCREEN\|DEVICE", "days": {"MON": {"from": "09:00", "to": "18:00"}, ...}}` |

**Config:**
| Command | Description | Params |
|---------|-------------|--------|
| `time` | Set timezone | `timeZone` (string) IANA format |
| `location` | Set GPS coordinates | body: `{"latitude": (double), "longitude": (double)}` |
| `setWeatherLocation` | Set weather location | `weatherLocation` (string) |
| `sensorConfig` | Configure sensors | body: JSON — ask user for format |
| `resetSensor` | Reset sensor config | — |
| `advancedConfiguration` | Set advanced config | `updateMethod` (enum): `SET`, `ADD_OR_UPDATE`, `REMOVE` — body: JSON, ask user for format |
| `updateRule` | Set update rules | body: JSON — ask user for format |
| `setUpdateVersionUpperLimit` | Limit app version | `version` (string) |
| `datasource` ⊖ | Bind datasource | `datasourceId` (string) |
| `dataRowId` ⊖ | Set data row binding | `dataRowId` (string) |

**Debug:**
| Command | Description | Params |
|---------|-------------|--------|
| `showName` | Show device name overlay | `enabled` (bool) |
| `requestLog` | Request device logs | `systemLog` (bool)=false, `systemReport` (bool)=false |

**Other:**
| Command | Description | Params |
|---------|-------------|--------|
| `update` | Update player app | `version` (string)? |
| `emergency` | Enable/disable emergency | `emergencyStatus` (bool) |
| `tags/{set\|add\|remove}` | Manage tags | body: `["tag1", "tag2"]` |
| `turnOnRapidPreviewMode` | Fast screenshot mode | `duration` (long) ms, `minimumDelayBetweenPreviews` (long) ms |
| `takeHighResPreview` | Request HD screenshot | — |
| `updateLicense` | Change license | `licenseOrderId` (long)?, `licenseType` (enum)?, `licenseStatus` (enum)? |

### Delete
```
DELETE /api/device/{id}
```

## Channel (Campaign)

**Terminology:** API uses `campaign` (level=WIDGET for channels, level=TOP for schedules), `messageGroup`, `message`. UI shows "Channel", "Schedule", "Sub-channel Group", "Sub-channel".

**How channels work:** Channels play through **Channel Widgets** embedded in Content/Slides. Multiple channels in a widget play **sequentially** by order (weight). Saturation balances play time. Channel tags filter which channels the widget includes. Some playback behaviors (balancing mode, units) are controlled by the **Channel Widget**, not Campaign settings.

**Channel dominance:**
```
Channel Widget (in Slide) → filters channels by tags
└── Main Channel → playback slot DOMINATES all below
    └── MessageGroup → orchestrates, respects sub-channel limits
        └── Message → defines its contribution limit
```
"All" at any level = cycle through all, but each contributes per its own setting.

**Key constraints:**
- Schedules (level=TOP) can only use type=CONTENT with one content item
- Channels can only contain ONE content type (no mixing media with content)
- `type` and `deviceSelectionMode` immutable (update silently ignored)

### Common Fields

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | CREATE | |
| `level` | enum | CREATE | `WIDGET` (channel) or `TOP` (schedule) |
| `type` | enum | CREATE | See type table |
| `version` | string | CREATE | Must be `"2.0"` |
| `enabled` | boolean | - | Default: true |
| `weight` | int | - | Order (lower first) |
| `saturation` | float | - | Balance weight between channels (higher = more play time) |
| `tags` | array | - | Tag array for channel widget filtering |
| `locked` | boolean | - | Prevent modifications |

### Device Targeting

| Mode | How to Target |
|------|---------------|
| `STATIC` | `deviceAssignment` in body (individual devices only) |
| `DYNAMIC` (default) | `deviceGroupAssignment` + `deviceTagCondition` + `teamAccessList` |
| `QUICK_FILTER` | `deviceQuickFilterId` field |

### Campaign Types

| Type | Level | Content Selection |
|------|-------|-------------------|
| `MESSAGE_GROUP` | WIDGET | MessageGroup → Message hierarchy |
| `CONTENT` | WIDGET or TOP | `contentAssignment` in body |
| `CONTENT_DYNAMIC` | WIDGET | `contentTagCondition` + `contentGroupAssignment` + `playedAssetTeamAccessList` |
| `ASSETS_STATIC` | WIDGET | `fileAssignment` in body |
| `ASSETS_DYNAMIC` | WIDGET | `fileTagCondition` + `fileFolderAssignment` |
| `CONTENT_QUICK_FILTER` | WIDGET | `contentQuickFilterId` |
| `ASSET_QUICK_FILTER` | WIDGET | `fileQuickFilterId` |
| `DSP_VISTAR_MEDIA` | WIDGET | Programmatic advertising (Vistar Media integration) |

### Assignments

**No separate endpoints.** Assignments are embedded in campaign/message create/update request body.

**Behavior:** Assignments are ADDITIVE - adding new items preserves existing ones. Use `removeIds` to remove.

**Pattern:**
```json
{
  "xxxAssignment": {
    "assignments": [{"xxxId": "id", "weight": 1}],
    "removeIds": ["id-to-remove"]
  }
}
```

| Campaign Type | Assignment Field | Item Structure |
|---------------|------------------|----------------|
| CONTENT | `contentAssignment` | `{contentId, weight}` |
| CONTENT_DYNAMIC | `contentGroupAssignment` | `{contentGroupId}` |
| ASSETS_STATIC | `fileAssignment` | `{fileId, weight}` |
| ASSETS_DYNAMIC | `fileFolderAssignment` | `{fileFolderId}` |
| MESSAGE_GROUP | `messageGroupAssignment` | `{messageGroupId}` (Long) |

**Device assignments:**

| Mode | Field | Item |
|------|-------|------|
| STATIC | `deviceAssignment` | `{deviceId}` |
| DYNAMIC | `deviceGroupAssignment` | `{deviceGroupId}` |

**Tag conditions:** `{"operator": "AND|OR", "tags": ["tag1", "tag2"]}`

Fields: `deviceTagCondition`, `contentTagCondition`, `fileTagCondition`

**Tip:** To target ALL devices/content/files in DYNAMIC mode, assign the root folder ID.

**Team filtering** (DYNAMIC modes):
- `teamAccessList` - filter devices by team (where to play)
- `playedAssetTeamAccessList` - filter content/files by team (what to play)

Format: `{"teams": [{"id": "team-uuid"}]}`

Required for non-OWNER team users (at least one team must be selected).

**Advertiser:** `advertiserId` for ad tracking.

### Playback Slot (Campaign)

**Slot mode mapping:**

| UI | `playMultipleItemsInSinglePlaybackSlot` | `playAllItemsInSinglePlaybackSlot` |
|----|----------------------------------------|-----------------------------------|
| One item/page/media | `false` | - |
| Specific N | `true` | `false` |
| All | `true` | `true` |

**UI labels by type:**
- CONTENT types: One page / Specific pages / All contents
- ASSETS types: One media / Specific media / All media
- MESSAGE_GROUP: One item / Specific items / All groups

**When "Specific N":**

| Field | Notes |
|-------|-------|
| `playbackSlotDuration` | Max slot duration (seconds). `undefined` = no limit |
| `playbackSlotNumberOfElementsToPlay` | Items per slot |

**contentShuffleMode** (item cycling):

| Type | BALANCED | SERIAL |
|------|----------|--------|
| CONTENT | One page per content | All pages per content |
| MESSAGE_GROUP | One item per sub-channel | All items per sub-channel |

**Type-specific fields:**

| Field | Applies to | Notes |
|-------|------------|-------|
| `defaultDuration` | ASSETS types | Duration for images (seconds). Default: 10, min: 3 |
| `duration` | All | Max item duration override (seconds) |
| `orderingMode` | DYNAMIC types | `RANDOM`, `ALPHABET`, `DEFAULT` |
| `skipDefaultPage` | CONTENT types | Legacy, rarely used |

### MessageGroup Fields

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | CREATE | |
| `weight` | int | - | Order (lower first) |

Note: `saturation`/`shuffleMode` fields exist but are not used by player.

### Message Fields

**Important:** A sub-channel can only play on a device if the parent channel is also allowed on that device.

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `name` | string | CREATE | |
| `messageGroupId` | long | CREATE | Parent group |
| `type` | enum | CREATE | `CONTENT`, `CONTENT_DYNAMIC`, `ASSETS_STATIC`, `ASSETS_DYNAMIC`, `CONTENT_QUICK_FILTER`, `ASSET_QUICK_FILTER` |
| `version` | string | CREATE | Must be `"2.0"` |
| `contentId` | string | type=CONTENT | |
| `weight` | int | - | Order (lower first) |
| `enabled` | boolean | - | |
| `affectedDateRanges` | JSON | - | Scheduling |

**Message playback (via `saturation` - different from Campaign!):**

| UI "Play" | `saturation` |
|-----------|--------------|
| One item/page | `1` |
| Specific N | `N` |
| All items | `0` |

Message has NO `playbackSlotDuration` field.

**Additional fields:**

| Field | Applies to | Notes |
|-------|------------|-------|
| `isPlayedElementsCappedByItemCount` | All types (when "Specific N") | If true and saturation=5 but only 3 items exist, plays 3 (not 1,2,3,1,2). Default: true |
| `contentShuffleMode` | CONTENT types | `BALANCED` (one page per content) or `SERIAL` (all pages) |
| `orderingMode` | MEDIA, or CONTENT+DYNAMIC | `DEFAULT`, `ALPHABET`, `RANDOM` |
| `skipDefaultPage` | CONTENT types | |
| `defaultDuration` | ASSETS types | For images (seconds) |

**Message device targeting:** Same as Campaign (deviceSelectionMode, deviceAssignment, deviceGroupAssignment, deviceTagCondition, deviceQuickFilterId).

**Message content assignments:** Same pattern as Campaign (contentAssignment, contentGroupAssignment, fileAssignment, fileFolderAssignment, contentTagCondition, fileTagCondition, playedAssetTeamAccessList).

### Scheduling

`affectedDateRanges`: See API Conventions → Scheduling for format.

`validFrom`/`validTo`: Frontend-provided for backend search optimization.

### Read

**Select:** plural form (`contentAssignments`, `deviceGroupAssignments`, `messageGroupAssignments`)

**Search:** entity prefix + plural (`campaignContentAssignments.contentId`, `messageDeviceGroupAssignments.deviceGroupId`)

```
GET /api/v2/campaign?search=level%3DWIDGET&select=id,name,type,messageGroupAssignments(id,name)
GET /api/messageGroup?select=id,name,weight                          # v1 only — there is no v2 messageGroup
GET /api/v2/message?search=messageGroupId%3D{id}&select=id,name,type,saturation,content(id,name)
GET /api/v2/campaign?search=campaignContentAssignments.contentId%3D{id}  # content usage
GET /api/v2/campaign?search=campaignMessageGroupAssignments.messageGroupId%3D{id}  # group usage
```

### Create

**Base campaign pattern:**
```json
POST /api/campaign
{"name":"...", "level":"WIDGET|TOP", "type":"...", "version":"2.0", "deviceSelectionMode":"DYNAMIC", ...type-specific}
```

**Type-specific fields to add:**

| Type | Required Fields |
|------|-----------------|
| CONTENT | `contentAssignment`, `contentShuffleMode` |
| CONTENT_DYNAMIC | `contentGroupAssignment`, `contentTagCondition?`, `orderingMode`, `contentShuffleMode` |
| ASSETS_STATIC | `fileAssignment`, `defaultDuration`, `orderingMode` |
| ASSETS_DYNAMIC | `fileFolderAssignment`, `fileTagCondition?`, `defaultDuration`, `orderingMode` |
| MESSAGE_GROUP | `messageGroupAssignment`, `contentShuffleMode` |

**Schedule (level=TOP):** `type=CONTENT` + `contentAssignment` + `contentShuffleMode:"BALANCED"` (required but ignored) + `affectedDateRanges`. Scheduling format: see API Conventions.

**MESSAGE_GROUP flow:**
```
POST /api/messageGroup {"name":"Group","customerId":123}
POST /api/campaign {..., "type":"MESSAGE_GROUP", "messageGroupAssignment":{"assignments":[{"messageGroupId":456}]}}
POST /api/message {"name":"Sub", "messageGroupId":456, "type":"CONTENT_DYNAMIC", "version":"2.0", "saturation":1, "contentGroupAssignment":{...}, "deviceSelectionMode":"DYNAMIC", ...}
```

**Playback slot (Specific N):** Add `playMultipleItemsInSinglePlaybackSlot:true`, `playAllItemsInSinglePlaybackSlot:false`, `playbackSlotNumberOfElementsToPlay:N`

### Update

```
PUT /api/campaign/{id} {"enabled":true, "weight":2, "contentAssignment":{"assignments":[...], "removeIds":[...]}}
PUT /api/messageGroup/{id} {"weight":2}
PUT /api/message/{id} {"enabled":true, "saturation":2}
```

### Delete

```
DELETE /api/message/{id}
DELETE /api/messageGroup/{id}  # cascades: deletes all messages in group
DELETE /api/campaign/{id}
```

**Constraints:** MessageGroup cannot be deleted while assigned to a campaign. Campaign delete does NOT cascade.

## Files

**Entity attributes:**

| Field | Type | Source | Notes |
|-------|------|--------|-------|
| `name` | string | upload | Filename |
| `contentType` | string | frontend | MIME type (browser detection, backend validates) |
| `size` | long | backend | File size in bytes |
| `width` | int | frontend | Image/video width (backend recalculates if missing) |
| `height` | int | frontend | Image/video height (backend recalculates if missing) |
| `tags` | array | upload | File tags |
| `validFrom` | datetime | upload | Validity start — skipped before this in lists |
| `validTo` | datetime | upload | Validity end — skipped after this in lists |
| `muted` | boolean | upload | Audio muted flag |
| `metaData` | JSON | frontend | Media metadata (extracted via mediainfo.js) |
| `crcCheckSum` | string | backend | CRC32 checksum (for duplicate detection) |
| `createDate` | datetime | backend | Upload timestamp |
| `creator` | object | backend | `.email`, `.name` |
| `fileFolderId` | string | upload | Parent folder ID |
| `fileFolderPath` | string | computed | Full folder path |
| `orientation` | string | computed | `landscape`, `portrait`, `square` |
| `location` | string | computed | Download URL |
| `thumbnail` | string | computed | Thumbnail URL |

**Thumbnails:** Frontend generates for images, videos, PDFs and uploads separately.

**Validity:** Skipped outside `validFrom`/`validTo` in playlists, channels, folder-connected galleries. Direct widget refs unaffected.

**Usage fields (SELECT-ONLY):**

| Field | Contains |
|-------|----------|
| `usageDetails` | Content references — which contents use this file |
| `duplicateDetails` | Files with same CRC checksum |
| `campaignUsageDetails` | Widget-level campaigns using this file |
| `topLevelCampaignUsageDetails` | Top-level campaigns (schedules) using this file |
| `messageUsageDetails` | Sub-channels (messages) using this file |

### Read
```
GET /api/v2/file?search=fileFolderId%3D{folderId}&select=id,name,contentType,size,thumbnail
GET /api/v2/file?search=fileFolderPath:{folderId}&select=id,name              # Recursive
GET /api/v2/file?search=tags:promo&select=id,name,tags                        # By tag
GET /api/v2/file?search=id%3D{id}&select=id,usageDetails                      # Content usage
```

**URL fields** (computed, include in `select`): `location`, `thumbnail`

**URL pattern** (construct if you have `id` and `customerId`):
```
Download:  /api/storage/customer/{customerId}/files/{fileId}
Thumbnail: /api/storage/customer/{customerId}/files/{fileId}.tmb
```

### Create

**Chunked upload** (large files, up to 10GB):
```
Step 1: POST /api/v2/file/upload/init
Body: {"name": "video.mp4", "size": 524288000, "totalChunks": 50, "contentType": "video/mp4", "parentId": "folderId"}
Response: {"uploadId": "...", "uploadUrls": {"1": "https://s3..."}, "maxChunkSize": 10485760, "name": "video.mp4", "size": 524288000, "totalChunks": 50}

Step 2: PUT /api/v2/file/upload/{uploadId}/part/{partNumber} with chunk binary

Step 3: POST /api/v2/file/upload/{uploadId}/complete

Step 4: PUT /api/v2/file/upload/{fileId}/thumbnail with image binary

Recovery: GET /api/v2/file/upload/{uploadId}/recover → {"uploadedChunks": [1,2,3], "missingChunks": [...]}
Cancel: DELETE /api/v2/file/upload/{uploadId}
```

**Simple upload** (small files):
```
POST /api/file?parentId={folderId}
Content-Type: multipart/form-data
Body: files=@video.mp4, previews=@thumbnail.png (preview filename MUST match file!)
```

**Optional params:** `tags`, `validFrom`, `validTo`, `muted`, `width`, `height`, `inheritParentTeams`, `fileIdToReplace`, `removeOldFile`

**MCP upload** (AI assistants cannot send binary):
- `file_upload` — UI file picker (preferred)
- `api_upload_from_url` — from public URL
- `api_proxy_url` + curl — scripted upload

### Update
```
PUT /api/file/{id}
Body: {"name": "renamed.mp4", "tags": ["promo"], "validFrom": 1704067200000, "validTo": 1735689600000, "muted": true}

POST /api/file/rename?fileId={id}&newName=newname.mp4
```

**Tags (bulk):**
```
POST /api/v2/file/tags/add?search={wbql}    Body: ["tag1", "tag2"]
POST /api/v2/file/tags/set?search={wbql}    Body: ["tag1"]
POST /api/v2/file/tags/remove?search={wbql} Body: ["tag1"]
```

**Validity (bulk):**
```
POST /api/v2/file/validity/set?search={wbql}&validFrom={timestamp}&validTo={timestamp}
```

**Replace references** (swap file in all content/channels):
```
PUT /api/file/replaceFileReference?fileIdToReplace={oldId}&sourceFileId={newId}&removeOldFile=true
```
Files are immutable — use replace to swap everywhere. Requires APPROVER role.

**Move**: See Folders section → `POST /api/fileFolder/moveBatchToFolder`

### Delete

**Permission:** EDITOR+ with edit access to the file.

**Required preflight:**
```
GET /api/v2/file?search=id%3D{id}&select=id,name,readOnly,campaignUsageDetails,topLevelCampaignUsageDetails,messageUsageDetails,usageDetails
```

**MCP safety rule:** Treat this preflight as mandatory; do not rely on DELETE-time validation.

| Result | Action |
|--------|--------|
| `readOnly=true` | Stop — caller has no write access |
| Any `campaignUsageDetails`, `topLevelCampaignUsageDetails`, or `messageUsageDetails` | Stop and report the Channel/Sub-channel names |
| `usageDetails.contentUsage[].deviceContentType="simpleLoop"` | File is in a Playlist; recommend playlist cleanup |
| Any other `usageDetails.contentUsage` | Warn that deletion leaves broken Content/Slide/Interactive/Legacy Schedule references; require explicit confirmation |

```text
DELETE /api/file/{id}?removeFromPlaylists={false|true}
Verify: GET /api/v2/file?search=id%3D{id}&select=id
```

`removeFromPlaylists` defaults to `false`. `true` removes direct file slides from Playlists only. It does not repair other content references. For MCP, pass it in the `api_delete` request `params`, execute the reviewed deletion with `parallel:false`, then verify the file is absent.

## Data Sources

Three categories: **INTERNAL** (user-managed data), **EXTERNAL** (fetched from URLs/APIs), **SYSTEM** (platform-generated from Wallboard's own resources).

**Entity attributes:**

| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Display name (unique per customer) |
| `sourceType` | enum | `INTERNAL`, `EXTERNAL`, `SYSTEM` |
| `structureType` | enum | `TABLE`, `CUSTOM`, `KEY_VALUE`, `LIST`, `FEED`, `WAY_FINDING` |
| `type` | enum | EXTERNAL only: data format (see table below) |
| `systemDatasourceType` | enum | SYSTEM only: `FILES`, `DEVICES`, `SCREENSHOT`, `AI_DATASOURCE`, `CALENDAR_MERGE` |
| `comment` | string | Description |
| `global` | boolean | Available to all customers (ADMIN only) |
| `deactivated` | boolean | Manually disabled |
| `editableByDisplay` | boolean | Allow devices to edit (INTERNAL only) |

**External source `type` values:**

| Type | Description | Key Fields |
|------|-------------|------------|
| `JSON` | JSON API | `remoteUrl`, `requestSettings` |
| `XML` | XML API (auto-converts to JSON) | `remoteUrl`, `requestSettings` |
| `RSS` | RSS feed | `remoteUrl`, `keepLastXDays`, `keepLastXItem` |
| `ICAL` | iCalendar format | `remoteUrl`, `dateFormat`, `timeFormat`, `timeZone` |
| `GOOGLESHEET_API` | Google Sheets | `credentialId`, `spreadSheetId`, `sheetId`, `firstRowIsHeader` |
| `CALENDAR` | Google/Microsoft calendar | `credentialId`, `calendarId` (Google) or `microsoftCalendarDatasourceType` (O365) |
| `SHAREPOINT_LISTS` | SharePoint lists | `credentialId`, `siteId`, `listId` |
| `MICROSOFT_WORKBOOK_V2` | Excel Online | `credentialId`, `microsoftWorkbookV2Parameters` |
| `JDBC` | Database query | `remoteUrl`, `jdbcUserName`, `jdbcPassword`, `jdbcQuery` |
| `WEATHER` | Weather API | `weatherParameters` |
| `SCREENSHOT` | URL screenshot | `screenshotParameters` |
| `WEBSITE_CONTENT` | Web scraping with AI | `webScraperParameters` |
| `CAP` | Emergency alerts | `capParameters` |
| `CUSTOM_INTEGRATION` | Custom integrations | `credentialId`, `dynamicParameters` |
| `AMADEUS_EVENT` | Amadeus Event datasource | `credentialId`, `amadeusEventParameters` |

**Credential types:** `GOOGLE`, `O365`, `CUSTOM` — set via `credentialId` + `credentialType` fields.

**Amadeus Event credential:** Uses custom credential identifier `AMADEUS_CREDENTIAL`.

**Amadeus Event parameters:** `locationId`, `windowsTimeZone`, `numberOfDays` (1-7). Returns JSON with top-level `events[]` containing event/group names, room/location fields, UTC timestamps, attendance fields, posted flag, and alternate labels.

**Refresh fields (EXTERNAL):**

| Field | Type | Notes |
|-------|------|-------|
| `refreshFrequency` | int | Refresh interval (seconds) |
| `cronExpressionParameters` | object | Alternative: `{cronExpression, timeZone}` |
| `nextRefreshTime` | datetime | Calculated next refresh (read-only) |
| `lastUpdated` | datetime | Last successful refresh |
| `errorCounter` | int | Consecutive errors (auto-deactivates at 25) |

**System datasource configuration:**

| `systemDatasourceType` | Description | Key Fields |
|------------------------|-------------|------------|
| `FILES` | File library data | `quickFilterId`, `maxElementCount` |
| `DEVICES` | Device inventory | `quickFilterId`, `maxElementCount` |
| `SCREENSHOT` | Content page screenshots | `screenshotParameters` |
| `AI_DATASOURCE` | AI-generated from other datasources | `aiParameters` |
| `CALENDAR_MERGE` | Merged calendars | `calendarMergeParameters` |

### Read
```
GET /api/v2/datasource?search=sourceType%3DINTERNAL&select=id,name,structureType
GET /api/v2/datasource?search=type%3DGOOGLESHEET_API&select=id,name,credentialId
GET /api/datasource/{id}/data?parseData=true   # Get data as JSON
GET /api/datasource/usageDetails?search=id%3D{id}   # Usage in content
```

### Create

**INTERNAL:**
```
POST /api/datasource
Body: {"name": "KPI Dashboard", "sourceType": "INTERNAL", "structureType": "CUSTOM"}
```

**EXTERNAL (URL-based):**
```
POST /api/datasource
Body: {"name": "News", "sourceType": "EXTERNAL", "type": "RSS", "structureType": "FEED", "remoteUrl": "https://example.com/feed.xml", "refreshFrequency": 3600}
```

**EXTERNAL (Google Sheets):**
```
POST /api/datasource
Body: {"name": "Sales", "sourceType": "EXTERNAL", "type": "GOOGLESHEET_API", "structureType": "TABLE", "credentialId": "...", "credentialType": "GOOGLE", "spreadSheetId": "...", "sheetId": "...", "firstRowIsHeader": true, "refreshFrequency": 3600}
```

**SYSTEM (Files):**
```
POST /api/datasource
Body: {"name": "Media Files", "sourceType": "SYSTEM", "systemDatasourceType": "FILES", "structureType": "TABLE", "quickFilterId": "...", "maxElementCount": 100}
```

### Update

**Metadata:**
```
PUT /api/datasource/{id}
Body: {"name": "Renamed", "refreshFrequency": 1800, "deactivated": false}
```

**Data (INTERNAL only):**
```
PUT /api/datasource/{id}/data
Body: {"visitors": 150, "sales": 42}
```

**Note:** Use `manage_datasource` for INTERNAL TABLE/CUSTOM data; the endpoint replaces the complete data document.

**Partial update (display API, public):**
```
POST /api/display/datasource/{id}/set?key=visitors&value=175
POST /api/display/datasource/{id}/merge   Body: {"newField": "value"}
```

**Refresh:**
```
POST /api/datasource/refresh/{id}?sync=true   # Force immediate refresh
DELETE /api/datasource/{id}/clearCache        # Clear cached resources
```

### Delete
```
DELETE /api/datasource/{id}
```
Checks usage first — fails if referenced in content.

## Folders

Three types with identical hierarchy: `deviceGroup`, `fileFolder`, `contentGroup`.

**Entity attributes (shared):**
| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Display name (absent on root folders) |
| `parentId` | string | Parent folder ID (`null` for root) |
| `deviceGroupPath\|fileFolderPath\|contentGroupPath` | string | Full path (computed, for recursive queries) |

**Type-specific attributes:**

| deviceGroup | | |
|-------------|--|--|
| `alertCount` | int | Active alerts (computed) |
| `campaignUsageDetails` | array | Channels targeting this folder (SELECT-ONLY) |
| `topLevelCampaignUsageDetails` | array | Schedules targeting this folder (SELECT-ONLY) |
| `messageUsageDetails` | array | Sub-channels targeting this folder (SELECT-ONLY) |
| `type` | enum | `NORMAL` (default) or `VIDEO_WALL` — set at creation, immutable. A `VIDEO_WALL` folder holds only devices (no sub-folders). |
| `videoWallMetaData` | JSON | Free-form video wall layout config (frontend-owned, structure undocumented). Settable only on `VIDEO_WALL` folders; absent (null) on others. |

| contentGroup | | |
|--------------|--|--|
| `isTemplateGroup` | boolean | Template folder flag |
| `contentCount` | int | Content items (computed) |
| `simpleLoopCount` | int | Playlists (computed) |
| `campaignUsageDetails` | array | Channels using content from this folder (SELECT-ONLY) |
| `messageUsageDetails` | array | Sub-channels using content from this folder (SELECT-ONLY) |

| fileFolder | | |
|------------|--|--|
| `fileFolderType` | int | 1=STORED (regular/synced), 2=FILTERED (virtual) |
| `quickFilterId` | string | QuickFilter ID (FILTERED type only) |
| `fileCount` | int | Files in folder (computed) |
| `autoSync` | boolean | Cloud sync enabled |
| `googleCredentialId` | string | Google credential (synced folders) |
| `microsoftTenantId` | string | Microsoft tenant (synced folders) |
| `canvaCredentialId` | string | Canva credential (synced folders) |
| `googleDriveFolderDetails` | object | Google Drive sync config |
| `oneDriveFolderDetails` | object | OneDrive sync config |
| `canvaFolderDetails` | object | Canva sync/export config |
| `campaignUsageDetails` | array | Channels linked to this folder — pulls all files dynamically (SELECT-ONLY) |
| `topLevelCampaignUsageDetails` | array | Schedules linked to this folder (SELECT-ONLY) |
| `messageUsageDetails` | array | Sub-channels linked to this folder (SELECT-ONLY) |
| `usageDetails` | array | Content items linked to this folder (SELECT-ONLY) |

**Root folders**: Auto-created per customer per type. Have `parentId=null`, no `name`. Cannot be deleted.

**⚠️ To create in root:** First GET root folder ID (`search=parentId%3Dnull`), then use that ID as parent. Cannot pass `null` directly.

### Read
```
GET /api/v2/{deviceGroup|fileFolder}?search=parentId%3Dnull&customerId={id}&select=id   # Root (ADMIN only)
GET /api/contentGroup/?search=parentId%3Dnull&customerId={id}&select=id                 # Root (ADMIN only)
GET /api/v2/{deviceGroup|fileFolder}?search=parentId%3D{id}&select=id,name              # Children
GET /api/contentGroup/?search=parentId%3D{id}&select=id,name                            # Children
GET /api/v2/deviceGroup?search=deviceGroupPath:{id}&select=id,name                      # Recursive
GET /api/v2/fileFolder?search=googleCredentialId!%3Dnull&select=id,name,autoSync        # Synced
GET /api/fileFolder/canvaFolders?credentialId={id}&folderId=root                        # Browse Canva folders for Connect Canva folder
```

**deviceGroup stats** (recursive — each group includes its subfolders):
```
GET /api/deviceGroup/getDeviceOnlineOfflineRatio?search=parentId%3D{id}   # All children at once
```
Returns `{groupId: {onlineCount, offlineCount}, ...}` for each matching group.

**Folder hierarchy** (flat list, build tree from parentId):
```
GET /api/v2/{deviceGroup|fileFolder}?select=id,name,parentId
GET /api/contentGroup/?select=id,name,parentId
```

### Create

**deviceGroup:**
```
POST /api/deviceGroup/{parentId}
Body: {"name": "Floor 1"}
```
Video wall folder: add `"type": "VIDEO_WALL"` to the body. A `VIDEO_WALL` folder holds only devices — sub-folders cannot be created inside it or moved into it. `type` is immutable after creation.

**contentGroup:** (`deviceContentType`: always use `"content"`)
```
POST /api/contentGroup/?parentGroupId={parentId}
Body: {"name": "Templates", "deviceContentType": "content"}
```

**fileFolder — type determines endpoint:**

| Type | Endpoint | Notes |
|------|----------|-------|
| Regular | `POST /api/fileFolder/?parentId={parentId}&name={name}` | No body, query params only |
| Filtered | `POST /api/fileFolder/addFilteredFileFolder?parentId={parentId}&name={name}&quickFilterId={qfId}` | Virtual view using QuickFilter |
| Google Drive | `POST /api/fileFolder/addSyncedGoogleDriveFolder?parentId={parentId}&name={name}` | Requires pre-authenticated GoogleCredential |
| OneDrive | `POST /api/fileFolder/addSyncedOneDriveFolder?parentId={parentId}&name={name}` | Requires pre-authenticated MicrosoftTenant |
| Canva | `POST /api/fileFolder/addSyncedCanvaFolder?parentId={parentId}&name={name}` | Requires pre-authenticated Canva credential; exports Canva designs into files |

**Synced folder body** (Google Drive example):
```json
{
  "googleCredentialId": "cred-uuid",
  "googleFolderId": "google-folder-id",
  "fileTypes": ["IMAGE", "VIDEO"]
}
```

**Canva synced folder body:**
```json
{
  "canvaCredentialId": "cred-uuid",
  "canvaFolderId": "canva-folder-id",
  "syncMode": "DOWNLOAD_ONCE",
  "syncFileRemoval": true,
  "syncFolderRemoval": true,
  "preferredVisualFormat": "PNG",
  "preferredTableFormat": "PNG",
  "exportStrategy": "WHOLE"
}
```

⚠️ **MCP limitation**: Creating synced folders requires OAuth-authenticated credentials. Use UI for credential setup, then API for folder creation.

### Update
```
PUT /api/deviceGroup/{id}   {"name": "Renamed"}
POST /api/fileFolder/rename?folderId={id}&newName=NewName
PUT /api/contentGroup/{id}  {"name": "Renamed"}
PUT /api/fileFolder/setAutoSync?folderId={id}&autoSync=true  # Toggle cloud sync
PUT /api/fileFolder/synchronize?folderId={id}                # Trigger manual sync
PUT /api/fileFolder/updateDesignExportFormat?folderId={id}&exportFormat=PNG&exportStrategy=WHOLE  # Re-export Canva design folder
```

**Move folders/items:**
```
POST /api/deviceGroup/moveToParent/{folderId}/{targetFolderId}   # Move folder (one at a time)
POST /api/v2/device/move  {"deviceIds": [...], "targetGroupId": "target"}  # Move devices (batch)

POST /api/contentGroup/move                                       # Move folders + items (batch)
Body: {"contentGroupIds": [...], "contentIds": [...], "targetGroupId": "..."}

POST /api/fileFolder/moveBatchToFolder                            # Move folders + files (batch)
Body: {"folderIds": [...], "fileIds": [...], "targetFolder": {"id": "..."}}
```

### Delete
```
DELETE /api/deviceGroup/{id}?targetGroupId={targetId}  # Devices move to target (default: parent)
DELETE /api/contentGroup/{id}?removeContents=true      # removeContents=false moves to root
```

**File folder — EDITOR+ with edit access; recursively deletes every contained folder and file:**

```text
Target:
GET /api/v2/fileFolder?search=id%3D{id}&select=id,name,parentId,readOnly,campaignUsageDetails,topLevelCampaignUsageDetails,messageUsageDetails,usageDetails

Descendant folders:
GET /api/v2/fileFolder?search=fileFolderPath:{id}&select=id,name,parentId,readOnly,campaignUsageDetails,topLevelCampaignUsageDetails,messageUsageDetails,usageDetails

Contained files:
GET /api/v2/file?search=fileFolderPath:{id}&select=id,name,readOnly,campaignUsageDetails,topLevelCampaignUsageDetails,messageUsageDetails,usageDetails
```

Apply the Files deletion rules to the target and every returned descendant/file. Stop if the target has `parentId=null`, any item is `readOnly`, or any Channel/Sub-channel usage exists. Summarize the full recursive scope. Non-Playlist content usage requires an explicit broken-reference warning and confirmation.

```text
DELETE /api/fileFolder/{id}?removeFromPlaylists={false|true}
Verify: GET /api/v2/fileFolder?search=id%3D{id}&select=id
```

`removeFromPlaylists=true` removes direct file slides for contained files and a direct folder slide for the folder named in that DELETE request. It does not clean direct Playlist folder slides that point to nested descendant folders. If cleanup is requested, order reviewed descendant DELETE requests deepest-first and the selected folder last in `api_delete` with `parallel:false` (fail-fast). For more than 20 folders, use successive batches of at most 20 and re-run preflight before each batch. Verify the selected folder is absent. Synced-folder deletion affects Wallboard data only, not the external cloud folder.

## Teams

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Display name (required for CREATE) |
| `comment` | string | Description |

**Response extras** (optional via query params):
| Field | Param | Notes |
|-------|-------|-------|
| `teamUserNumber` | `includeTeamUserNumber=true` | Count of users |
| `team{Resource}Number` | `includeTeamResourceNumber=true` | Counts per resource type |

Teams control resource visibility. Resources without team → customer setting determines if visible to all or none.

**Resource assignment pattern** — applies to Read (query) and Update (modify):

`{resourceType}` values: `user`, `device`, `deviceGroup`, `deviceContent`, `contentGroup`, `file`, `fileFolder`, `datasource`, `campaign`, `message`, `messageGroup`, `microsoftTenant`, `googleCredential`, `advertiser`, `customCredential`, `alertRule`, `notificationChannel`, `webhookEventAction`, `webhookApiKey`, `deviceInstallRule`, `quickFilter`

### Read
```
GET /api/team/?search=name:Marketing                   # Paged list with search
GET /api/team/{resourceType}Assignments?teamId={id}    # Resources assigned (IDs + readOnly)
GET /api/team/{resourceType}/{resourceId}/assignments  # Teams assigned (IDs + readOnly)

Full entity data via WBQL (when you need more than IDs):
GET /api/v2/device?search=teamAssignments.team.id%3D{teamId}              # Devices in team
GET /api/team?search=teamDeviceAssignments.device.id%3D{deviceId}         # Teams for device
```

### Create
```
POST /api/team/
Body: {"name": "Marketing Team", "comment": "Content editors"}
```

### Update
```
PUT /api/team/?teamId={id}
Body: {"name": "Updated Name"}

Assign/unassign resources (from team side):
PUT /api/team/update{ResourceType}Assignments?teamId={id}
Body: {"resourcesToAdd": [{"id": "xxx", "readOnly": false}], "resourceIdsToRemove": ["yyy"]}

Assign/unassign teams (from resource side) — all v1:
POST /api/{resource}/updateTeamAssignments?{resource}Id={id}
Body: {"assignToTeams": [{"teamId": "xxx", "readOnly": false}], "removeFromTeamIds": ["yyy"]}
```
Exception: user uses `email` param instead of `userId`.

### Delete
```
DELETE /api/team/{id}
```

## Tags

Tags are per-customer, organized by type — not standalone entities.

**Tag types:** `DEVICE`, `CONTENT`, `CAMPAIGN`, `MESSAGE`, `FILE`

**Format rules:** Cannot start with `-` or whitespace. Allowed: letters, numbers, `-_#."`. Max 2048 chars total.

### Read
```
GET /api/tag                                           # All tags by type
GET /api/tag?select=deviceTags,campaignTags            # Only specific types
GET /api/tag/suggest?keyword={prefix}&entity=DEVICE    # Autocomplete (startsWith, case-insensitive)
```
Selectable: `deviceTags`, `deviceContentTags`, `campaignTags`, `messageTags`, `fileTags`
Response: `{"deviceTags": {"lobby": {"count": 5, "comment": "..."}}, ...}`

### Create
```
POST /api/tag?tagType=DEVICE
Body: {"tag": "lobby", "comment": "Lobby displays"}
```

### Update
```
PUT /api/tag?tag=lobby&tagType=DEVICE
Body: {"comment": "Updated description"}
```

**Update tags on entities:**

| Entity | Method | Body |
|--------|--------|------|
| Device | `POST /api/v2/device/tags/{add\|set\|remove}?search={wbql}` | `["tag1"]` |
| DeviceContent | `POST /api/v2/deviceContent/tags/{add\|set\|remove}?search={wbql}` | `["tag1"]` |
| File | `POST /api/v2/file/tags/{add\|set\|remove}?search={wbql}` | `["tag1"]` |
| DeviceGroup | `PUT /api/deviceGroup/{id}` | `{"tags": ["tag1"]}` |
| Campaign | `PUT /api/campaign/{id}` | `{"tags": ["tag1"]}` |
| Message | `PUT /api/message/{id}` | `{"tags": ["tag1"]}` |

Note: `/api/v2/campaign` and `/api/v2/message` are read-only endpoints.

Bulk operations: `add` (append), `set` (replace all), `remove`.

### Delete
```
DELETE /api/tag?tag=lobby&tagType=DEVICE
```
Cannot delete tags still in use.

## User

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `email` | string | Primary key, unique identifier |
| `name` | string | Display name |
| `role` | enum | `ADMIN`, `OWNER`, `TECHNICIAN`, `APPROVER`, `EDITOR`, `VIEWER`, `DEVICE_USER` |
| `password` | string | Write-only, min 8 chars. Optional if SSO-only |
| `authProvider` | enum | `LOCAL`, `LDAP`, `KEYCLOAK`, `SAML` — immutable after creation |
| `editorLevel` | enum | `BASIC`, `ADVANCED`, `PROFESSIONAL` |
| `language` | string | UI language code (e.g., `en`, `de`) |
| `comment` | string | Description |
| `address` | string | Physical address |
| `readOnly` | boolean | Read-only flag |
| `restricted` | boolean | If true, sees only team-assigned resources |
| `use2FA` | boolean | Two-factor authentication enabled |
| `forceToSet2FA` | boolean | Must set up 2FA on next login |
| `ssoLoginEnabled` | boolean | SSO login allowed |
| `usernamePasswordLoginEnabled` | boolean | Password login allowed |
| `magicCodeLoginEnabled` | boolean | Passwordless login allowed |
| `oauthPasswordFlowEnabled` | boolean | OAuth password grant enabled |
| `trustedAdmin` | boolean | Elevated admin trust (ADMIN only) |
| `lastLogin` | datetime | Last login time |
| `lastActivity` | datetime | Last activity |
| `createdDate` | datetime | Account creation |
| `hasProfilePicture` | boolean | Profile picture exists (computed) |
| `profilePictureApiPath` | string | Avatar URL (computed) |
| `active` | boolean | Account active (computed) |
| `microsoftSsoEnabled` | boolean | Microsoft SSO connected (computed) |
| `pinCodePresent` | boolean | PIN code set (computed) |
| `customer` | object | `.id`, `.name`, `.expirationDate`, `.force2FA` — embedded |
| `userInterfaceProfile` | object | `.id`, `.name` — UI customization profile |

**Endpoints:** `/api/v2/user` (read), `/api/user` (write)

**⚠️ Write protection:** DELETE, password, 2FA always require `sensitive_action`. POST only when role is OWNER or ADMIN — other roles use `api_write` directly. PUT with sensitive fields (password, use2FA, readOnly, restricted, trustedAdmin, role→OWNER/ADMIN) requires `sensitive_action`.

### Read
```
GET /api/v2/user?select=email,name,role,lastLogin,customer(id,name)
GET /api/v2/user?search=role%3DEDITOR&select=email,name
GET /api/v2/user?search=teamAssignments.team.id%3D{teamId}         # Users in team
GET /api/v2/user?search=restricted%3Dtrue                          # Restricted only
GET /api/v2/user?search=use2FA%3Dtrue                              # 2FA enabled
GET /api/v2/user/me                                                # Current user
GET /api/user/simple?customerId={id}                               # Dropdown list (email+name only)
```

### Create
```
POST /api/user/?customerId={id}&teamIds={id1},{id2}
Body: {"email": "john@example.com", "name": "John Smith", "role": "EDITOR", "password": "SecurePass1!", "language": "en"}
```

| Field | Required | Notes |
|-------|----------|-------|
| `email` | ✓ | Unique, valid email format |
| `name` | ✓ | Display name |
| `role` | ✓ | See role enum above |
| `password` | * | Required unless SSO-only. Min 8 chars |
| `customerId` | query | Required for non-ADMIN |
| `teamIds` | query | Comma-separated team UUIDs |

Auto-sends password setup email to new user.

### Update
```
PUT /api/user/?email={email}
Body: {"name": "New Name", "role": "APPROVER", "readOnly": false}
```

**Team assignments:**
```
POST /api/user/updateTeamAssignments?customerId={id}&email={email}
Body: {"assignToTeams": [{"teamId": "xxx", "readOnly": false}], "removeFromTeamIds": ["yyy"]}
```

**Password:** `POST /api/user/password?oldPassword={old}&newPassword={new}`

**2FA:** `POST /api/user/2fa?totp={code}&enable=true`

**Profile picture:** `POST /api/v2/user/profilePicture` (multipart), `DELETE /api/v2/user/profilePicture`

### Delete
```
DELETE /api/user/?email={email}
DELETE /api/user/myAccount              # Self-delete
```

## Quick Filters

QuickFilters are saved WBQL queries for dynamic content/device/file selection. Used by channels, system datasources, and filtered folders.

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Unique per customer |
| `filteredEntityType` | enum | `FILE`, `CONTENT`, `DEVICE` — immutable after creation |
| `criteria` | object | Filtering logic (see below) |
| `listed` | boolean | Visible in filter dropdowns |
| `custom` | boolean | Custom filter flag |
| `readOnly` | boolean | True if used by campaigns/messages (computed) |
| `campaignUsageDetails` | object | Channels using this filter (SELECT-ONLY) |
| `messageUsageDetails` | object | Sub-channels using this filter (SELECT-ONLY) |

**Criteria structure** (polymorphic by `filteredEntityType`):
```json
{
  "type": "FILE|CONTENT|DEVICE",
  "search": "WBQL query string",
  "folderId|groupId|deviceGroupId": "folder-uuid",
  "searchRecursively": true,
  "name": "name filter",
  "tags": ["tag1", "tag2"]
}
```

FILE criteria adds: `validFileOnly`, `isUndefinedValidityValid`

### Read
```
GET /api/v2/quickFilter?select=id,name,filteredEntityType,listed
GET /api/v2/quickFilter?search=filteredEntityType%3DDEVICE
GET /api/quickFilter/{id}
```

### Create
```
POST /api/quickFilter/?customerId={id}&teamIds={id1},{id2}
Body: {
  "name": "Active Promo Content",
  "filteredEntityType": "CONTENT",
  "criteria": {"type": "CONTENT", "search": "tags:promotion", "searchRecursively": true},
  "listed": true
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | ✓ | Unique per customer |
| `filteredEntityType` | ✓ | Cannot change after creation |
| `criteria` | ✓ | Must match `filteredEntityType` |

### Update
```
PUT /api/quickFilter/?quickFilterId={id}
Body: {"name": "Updated", "criteria": {...}, "listed": false}
```

### Delete
```
DELETE /api/quickFilter/?quickFilterId={id}
```
Cannot delete if used by campaigns, messages, or datasources.

## Actions

Actions (WebhookEventActions) define automated responses triggered by API-key-authenticated external webhook calls. External systems POST to a webhook URL with an `eventId`, and Wallboard executes the configured action.

**Permissions:** Action CRUD requires `TECHNICIAN` (Technical Manager) or higher. Webhook API keys also require `TECHNICIAN+`, but their long-lived `token` is a secret: create, copy, update, and delete keys in **Settings > Integrations > API Keys**. MCP may read key metadata, but always strips `token`; key mutations and proxy access are blocked.

**Trigger contract:** After the user creates/copies a key in the UI, configure the external system directly. Prefer the header form to keep the token out of query strings, which are commonly logged, copied, or shared. Never ask the user to paste the token into AI context.

```http
POST /public-api/integration/webhooks?eventId={eventId}
X-Webhook-ApiKey: <secret from UI>
Content-Type: application/json

{...action-specific fields...}
```

The example uses JSON; XML is also supported with `Content-Type: application/xml`. In either payload format, `eventId` or `event_id` may replace the query parameter. A successful response means the action was accepted for asynchronous execution, not that the target device/datasource operation has completed. The UI can generate query-token links for systems that cannot send headers; do not construct or expose those links through MCP.

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Display name |
| `eventId` | string | Webhook trigger ID, unique per customer |
| `enabled` | boolean | Whether action is active |
| `action` | enum | Action type (see categories below) |
| `actionParams` | map | Action-specific key-value parameters |
| `targetData` | object | `{"targetIds": ["id1", "id2"]}` |
| `targetId` | string | Convenience: single target ID |
| `targetName` | string | Target entity name (computed) |

**Action type categories:**

| Category | Actions | Target |
|----------|---------|--------|
| Emergency | `ENABLE_EMERGENCY_ON_DEVICE{_TAG\|_GROUP\|_ALL}`, `DISABLE_...` | Device(s) |
| Content control | `PAUSE_CONTENT_ON_DEVICE{...}`, `RESUME_...`, `REFRESH_...` | Device(s) |
| Content assign | `ASSIGN_CONTENT_ON_DEVICE{...}`, `PREVIEW_...` | Device(s) |
| Device control | `RESTART_DEVICE{...}`, `WAKE_UP_DEVICE{...}`, `SNOOZE_DEVICE{...}` | Device(s) |
| Display | `SHOW_TOAST_MESSAGE_DEVICE{...}`, `CHANGE_VOLUME_DEVICE{...}` | Device(s) |
| URL loading | `LOAD_URL_ON_DEVICE{...}` | Device(s) |
| Sensor events | `SEND_SENSOR_EVENT_TO_DEVICE{...}` | Device(s) |
| Datasource | `REFRESH_DATASOURCE`, `REFRESH_DATASOURCE_ALL`, `SET_INTERNAL_DATASOURCE`, `MERGE_INTERNAL_DATASOURCE`, `INCREASE_VALUE_IN_DATASOURCE`, `DECREASE_VALUE_IN_DATASOURCE`, `INSERT_CAP_DATASOURCE`, `DELETE_BY_KEY_INTERNAL_DATASOURCE`, `INSERT_TO_ARRAY_INTERNAL_DATASOURCE`, `REMOVE_FROM_ARRAY_INTERNAL_DATASOURCE`, `EMPTY_ARRAY_INTERNAL_DATASOURCE`, `ROTATE_ARRAY_INTERNAL_DATASOURCE`, `REPLACE_OR_MERGE_ELEMENT_IN_ARRAY_INTERNAL_DATASOURCE` | Datasource |
| Campaign | `ENABLE/DISABLE_CAMPAIGN{_BY_TAGS}`, `CHANGE_SATURATION/PRIORITY_CAMPAIGN{_BY_TAGS}` | Campaign |
| File/Folder | `UPLOAD_FILE_TO_FOLDER`, `FORCE_SYNC_SHARED_FOLDER` | Folder |

Device targeting suffixes: no suffix = specific device, `_TAG` = by tag, `_GROUP` = by folder, `_ALL` = all devices.

### Read
```
GET /api/webhookEvent/actions?customerId={id}
GET /api/webhookEvent/actions/{actionId}
GET /api/webhookEvent/simple?customerId={id}                       # Dropdown (id+name)
```

### Create
```
POST /api/webhookEvent/actions?customerId={id}&teamIds={id1}
Body: {
  "name": "Emergency Lobby",
  "eventId": "emergency.lobby.on",
  "enabled": true,
  "action": "ENABLE_EMERGENCY_ON_DEVICE_TAG",
  "actionParams": {"tag": "lobby"},
  "targetData": {"targetIds": ["device-group-id"]}
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | ✓ | Display name |
| `eventId` | ✓ | Unique per customer |
| `action` | ✓ | Valid WebhookActionType enum |

### Update
```
PUT /api/webhookEvent/actions/{actionId}
Body: {"name": "Updated", "enabled": false, "actionParams": {"url": "https://new.com"}}
```

### Delete
```
DELETE /api/webhookEvent/actions/{actionId}
```

## Alert Rules

Alert rules monitor system conditions and send notifications through reusable notification channels.

**Permissions:** Alert rule and notification channel CRUD requires `TECHNICIAN` (Technical Manager) or higher. Active device/datasource alerts are readable by `VIEWER` or higher; the generic alert list requires `OWNER` or higher.

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Unique per customer |
| `comment` | string | Description |
| `enabled` | boolean | Whether rule is active |
| `@type` | string | Rule type discriminator (see below) |
| `condition` | object | Rule-specific condition (see below) |
| `notificationChannels` | array | Existing channel references: `[{"id": 123}]` |
| `delayEvaluateAfterViolationMinutes` | int | Delay before re-evaluation |
| `workingDays` | object | Lowercase weekday booleans; required for evaluation |
| `workingTime` | object | Required; all-day or bounded `HH:mm` window |

**User-facing rule types (`@type`):**

| Type | Condition `@type` | Condition fields |
|------|-------------------|------------------|
| `DeviceOffline` | `DeviceOfflineAndNoActivityForMinutes` | `inactiveMinutes`, `deviceGroupId`, `recursive`, `excludeDeviceIds`, `excludeDeviceGroupIds`, `tagFilter` |
| `DatasourceError` | `DatasourceRefreshErrorCondition` | `threshold`; evaluation is currently fixed to error count `>= threshold` |

`DeviceMetricChanged` and `DeviceStatusChanged` exist in backend code but are not exposed by the current UI; do not create them unless explicitly required.

**Scheduling shapes:**
```json
{
  "workingDays": {"monday": true, "tuesday": true, "wednesday": true, "thursday": true, "friday": true, "saturday": true, "sunday": true},
  "workingTime": {"allDay": true, "from": null, "end": null, "timeZone": "GMT"}
}
```

For a bounded window use `{"allDay":false,"from":"09:00","end":"17:00","timeZone":"GMT+01:00"}`. `timeZone` is required, and `end` must be after and different from `from`.

### Notification Channels

Create or reuse a channel before creating a rule. A rule references channels **only by existing numeric ID**; inline channel definitions fail lookup.

| Field | Type | Notes |
|-------|------|-------|
| `name` | string | Unique per customer |
| `comment` | string | Required; use `""` when no description is needed |
| `channelType` | enum | `EMAIL`; `SMS` only when SMS/Twilio is configured |
| `notify` | string[] | Email addresses or phone numbers matching `channelType` |

| Operation | Endpoint | Body / notes |
|-----------|----------|--------------|
| List | `GET /api/alertRuleNotificationChannel?customerId={id}` | Paged; supports `search`, `sort`, `page`, `size` |
| Read | `GET /api/alertRuleNotificationChannel/{channelId}` | Single channel |
| Options | `GET /api/alertRuleNotificationChannel/simple?customerId={id}` | `id`, `name` list |
| Create | `POST /api/alertRuleNotificationChannel?customerId={id}&teamIds={teamId:false}` | `{"name":"IT Email","comment":"","channelType":"EMAIL","notify":["it@example.com"]}` |
| Update | `PUT /api/alertRuleNotificationChannel/{channelId}` | Partial body; non-null fields are updated |
| Delete | `DELETE /api/alertRuleNotificationChannel/{channelId}` | Fails while assigned to any alert rule |

### Read
```
GET /api/alertRule?customerId={id}
GET /api/alertRule/{alertRuleId}
GET /api/alertRule/simple?customerId={id}                          # Dropdown (id+name)

GET /api/alert/device?customerId={id}&search={criteria}            # Active device alerts, paged; VIEWER+
GET /api/alert/device/all?customerId={id}&search={criteria}        # Active device alerts, unpaged; VIEWER+
GET /api/alert/datasource?customerId={id}&search={criteria}        # Active datasource alerts, paged; VIEWER+
GET /api/alert?customerId={id}&search={criteria}                   # Generic alert rows, paged; OWNER+
```

Device/datasource alert DTO common fields: `id`, `createdAtDate`, `alertRuleId`, `alertRuleName`. Device alerts add `deviceId`, `deviceName`, `deviceLastActivity`, `deviceLastStatusChange`, `deviceGroupId`, `deviceGroupPath`; datasource alerts add `datasourceId`, `datasourceName`, `datasourceErrorCounter`, `datasourceLastError`. Generic alert rows contain `id`, `createdAtDate`, `alertRuleId`, `customerId`.

### Create
```
POST /api/alertRule?customerId={id}&teamIds={teamId:false}
Body: {
  "name": "Lobby Offline Alert",
  "comment": "",
  "@type": "DeviceOffline",
  "enabled": true,
  "condition": {
    "@type": "DeviceOfflineAndNoActivityForMinutes",
    "inactiveMinutes": 30,
    "deviceGroupId": "group-id",
    "recursive": true
  },
  "notificationChannels": [{"id": 123}],
  "delayEvaluateAfterViolationMinutes": 5,
  "workingDays": {"monday": true, "tuesday": true, "wednesday": true, "thursday": true, "friday": true, "saturday": true, "sunday": true},
  "workingTime": {"allDay": true, "from": null, "end": null, "timeZone": "GMT"}
}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | ✓ | Unique per customer |
| `@type` | ✓ | Rule type |
| `enabled` | ✓ | Active flag |
| `condition` | ✓ | Must match `@type` |
| `notificationChannels` | | Existing `{"id": number}` references; include at least one to deliver notifications |
| `workingDays` | ✓ | Null rules never evaluate |
| `workingTime` | ✓ | Backend rejects null; bounded windows require timezone/from/end |

### Update
```
PUT /api/alertRule/{alertRuleId}
Body: {full rule representation from GET, with the intended changes}
```

This is a full replacement-style update, not a patch. Preserve `name`, `@type`, matching `condition`, `workingDays`, `workingTime`, and channel ID references. Do not change the rule type; delete and recreate when a different type is required.

### Delete
```
DELETE /api/alertRule/{alertRuleId}
```

## Proof of Play

**READ-ONLY** reporting entity. Playback statistics collected by devices and aggregated by the Display Stat microservice. No create/update/delete operations.

**Base endpoint:** `/api/v2/proof-of-display`

**Required params for all queries:**
| Param | Type | Notes |
|-------|------|-------|
| `customerId` | number | Tenant ID |
| `from` | number | Start timestamp (Unix ms) |
| `timeZone` | string | IANA timezone (e.g., `Europe/Budapest`) |

**Optional params:**
| Param | Type | Notes |
|-------|------|-------|
| `to` | number | End timestamp (defaults to now) |
| `resolution` | enum | `HOURLY`, `DAILY`, `WEEKLY`, `MONTHLY`, `TOTAL` |
| `groupBy` | enum | `ADVERTISER`, `ASSET`, `CAMPAIGN`, `CONTENT`, `CONTENT_PAGE`, `DEVICE`, `DEVICE_GROUP`, `MEDIA_FILE`, `MESSAGE`, `WIDGET` |
| `search` | string | Text filter |
| `sort` | string | Sort field |
| `limit` | number | Max results |

**Constraints:** Max query period 3 months. Data retention 1 year.

**Response fields:**
| Field | Type | Notes |
|-------|------|-------|
| `duration` | number | Display duration (ms) |
| `impressionStartCount` | number | Play start count |
| `impressionEndCount` | number | Completed plays |
| `clickCount` | number | User interactions |
| `viewerCount` | number | Viewer impressions |
| `viewerAttention` | number | Total view time (ms) |
| `viewerAttentionRatio` | number | Attention ratio |
| `viewerAverageAttention` | number | Average attention (ms) |
| `readableDateTime` | string | Human-readable timestamp |

### Read

**By entity type:**
```
GET /api/v2/proof-of-display/device/{deviceId}?customerId={id}&from={ts}&timeZone=Europe/Budapest&resolution=DAILY
GET /api/v2/proof-of-display/content/{contentId}?customerId={id}&from={ts}&timeZone=Europe/Budapest
GET /api/v2/proof-of-display/campaign/{campaignId}?customerId={id}&from={ts}&timeZone=Europe/Budapest
GET /api/v2/proof-of-display/file/{fileId}?customerId={id}&from={ts}&timeZone=Europe/Budapest
GET /api/v2/proof-of-display/message/{messageId}?customerId={id}&from={ts}&timeZone=Europe/Budapest
```

**Aggregated (all entities of type):**
```
GET /api/v2/proof-of-display/device/all?customerId={id}&from={ts}&timeZone=Europe/Budapest
GET /api/v2/proof-of-display/content/all?customerId={id}&from={ts}&timeZone=Europe/Budapest
GET /api/v2/proof-of-display/campaign/all?customerId={id}&from={ts}&timeZone=Europe/Budapest
GET /api/v2/proof-of-display/file/all?customerId={id}&from={ts}&timeZone=Europe/Budapest
```

**Dashboard (top played):**
```
GET /api/v2/proof-of-display/content/dashboard/top/played?customerId={id}&from={ts}&timeZone=Europe/Budapest&limit=10
GET /api/v2/proof-of-display/campaign/dashboard/top/played?customerId={id}&from={ts}&timeZone=Europe/Budapest&limit=10
GET /api/v2/proof-of-display/file/dashboard/top/played?customerId={id}&from={ts}&timeZone=Europe/Budapest&limit=10
```

**CSV export:** Append `/csv` to any endpoint above.

## Logs

**READ-ONLY** audit trail. Runs in a separate microservice (wb-log-service) behind the security gateway. OWNER+ required.

**Response fields:**
| Field | Type | Notes |
|-------|------|-------|
| `time` | datetime | Timestamp |
| `logLevel` | enum | `DEBUG`, `INFO`, `WARN`, `ERROR` |
| `message` | string | Log message (max 500 chars, trimmed) |
| `userEmail` | string | Acting user |
| `deviceId` | string | Associated device |
| `deviceName` | string | Device name (enriched) |
| `contentId` | string | Associated content |
| `contentName` | string | Content name (enriched) |
| `datasourceId` | string | Associated datasource |
| `datasourceName` | string | Datasource name (enriched) |
| `customerId` | number | Tenant ID |
| `customerName` | string | Tenant name (enriched) |

### Read
```
GET /api/v2/log?customerId={id}&page=0&size=20&order=DESC
GET /api/v2/log?customerId={id}&search=error+device&from={ts}&to={ts}
GET /api/v2/log/csv?customerId={id}&search=error                     # CSV export
```

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `customerId` | number | - | Required for ADMIN |
| `page` | int | 0 | Page number |
| `size` | int | 20 | Page size |
| `search` | string | - | Full-text search |
| `order` | enum | `DESC` | `ASC` or `DESC` by time |
| `from` | number | - | Start timestamp (Unix ms) |
| `to` | number | - | End timestamp (Unix ms) |

## Device Metrics

**READ-ONLY** device monitoring data. Runs in a separate microservice (wb-device-metric-service) behind the security gateway. VIEWER+ required.

**MCP:** Use `api_read` with `method:"POST"` and `body:{"metricTypes":["CPU"]}` for graphs. Only this specific POST route is allowed through the read tool.

**Endpoints:** `/api/deviceStat/{deviceId}/*`

**Metric type categories:**
| Category | Types |
|----------|-------|
| CPU | `CPU`, `CPU_LOAD_1`, `CPU_LOAD_5`, `CPU_TEMP` |
| GPU | `GPU`, `GPU_TEMP`, `GPU_MEM`, `FPS` |
| Memory | `USED_SYS_MEM`, `USED_VM_MEM`, `USED_APP_MEM` |
| Storage | `USED_STORAGE`, `FILE_COUNT`, `U_FILE_COUNT` |
| Network | `NET_APP_RX`, `NET_APP_TX`, `NET_SYS_RX`, `NET_SYS_TX`, `NET_APP_SERVER_RX`, `NET_APP_SERVER_TX`, `REQ_COUNT`, `SERVER_AVG_REQ`, `SERVER_PING` |
| System | `WIFI_SIGNAL_DBM`, `WIFI_SIGNAL_LEVEL`, `NET_INTERNET_CONNECTION`, `BATTERY` |
| Threads | `NUMBER_OF_THREADS`, `NUMBER_OF_PROCESSES`, `GC_COUNT`, `GC_RUN_AVG` |
| Logs | `LOG_INFO`, `LOG_WARN`, `LOG_ERROR` |
| App events | `STARTUP_COUNT`, `CRASH_COUNT`, `WEB_VIEW_CRASH_COUNT`, `ANR_COUNT`, `SYSTEM_ERROR_COUNT`, `FRONTEND_PROCESS_CRASH_COUNT` |
| Sensors | `SENSOR_EVENT_COUNT`, `USER_INTERACTION_COUNT` |
| Status | `STATUS`, `RECONNECT` |

**Graph response structure:**
```json
{
  "graphs": {
    "CPU": {
      "values": {"1706745600000": 45.2, "1706746200000": 52.1},
      "avg": 48.6, "min": 12.0, "max": 95.3, "sum": 4860.0, "count": 100,
      "noDataIntervals": [{"from": 1706750000000, "to": 1706753600000}]
    }
  }
}
```

### Read
```
GET /api/deviceStat/{deviceId}/supportedMetrics
POST /api/deviceStat/{deviceId}/graphs?from={ts}&to={ts}&dataPoints=50
Body: {"metricTypes": ["CPU", "USED_SYS_MEM", "FPS"]}
```

| Param | Type | Default | Notes |
|-------|------|---------|-------|
| `from` | number | 30 days ago | Start timestamp (Unix ms) |
| `to` | number | now | End timestamp (Unix ms) |
| `dataPoints` | number | 50 | Number of aggregated points (max 1000) |

## User Interface Profiles

Controls which UI elements are visible for specific users. OWNER+ required.

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `id` | number | Auto-generated |
| `name` | string | Profile name |
| `comment` | string | Description |
| `hiddenUIElementRules` | JSON | Frontend-owned, versioned rule document |
| `customerId` | number | Tenant ID |

**Rules:** The frontend defines the JSON, not the backend. Current documents use `version:"2.1"`; in `dashboard`, `newUi` and `playlistEditor`, true enables, false disables, null/omitted inherits. Keys are namespace/version-specific; preserve other keys from a profile configured in the matching frontend. Rules do not grant API permissions.

**Endpoints:** `/api/v2/userInterfaceProfile`

### Read
```
GET /api/v2/userInterfaceProfile?customerId={id}&select=id,name,comment
GET /api/v2/userInterfaceProfile?search=name%3DDefault&select=id,name,hiddenUIElementRules
```

### Create
```
POST /api/v2/userInterfaceProfile?customerId={id}
Body: {"name":"Limited Editor","hiddenUIElementRules":{"version":"2.1","legacy":{},"newUi":{},"dashboard":{"DEVICES_SEE":false},"loopEditor":{},"contentEditor":{},"playlistEditor":{},"regionalFormatSettings":{}}}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | ✓ | Profile name |
| `hiddenUIElementRules` | ✓ | JSON rules |

### Update
```
PUT /api/v2/userInterfaceProfile/{id}
Body: {"name":"Updated"}
```
Omitting `hiddenUIElementRules` preserves it; sending it replaces the whole JSON value. To change one rule, read the current rules, modify that key and send the complete document.

**Assign/remove users:**
```
POST /api/v2/userInterfaceProfile/{id}/assign
Body: {"assignUserIds": ["user1@example.com", "user2@example.com"], "removeUserIds": ["old@example.com"]}
```

### Delete
```
DELETE /api/v2/userInterfaceProfile/{id}
```

## License Orders

Manages device license purchases. UI calls these **"Licenses"** (customer view) or **"Customer Licenses"** (admin view). Backend entity: `licenseOrder`. Related: `licensePackage` (admin-only templates defining tiers/pricing).

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `id` | number | Auto-generated |
| `orderDate` | datetime | Order date (required) |
| `createDate` | datetime | Creation timestamp (auto-set) |
| `deviceLicenseCount` | number | Number of device licenses |
| `pricePerDevice` | float | Price per device (admin-set) |
| `currency` | string | Currency code |
| `comment` | string | Order notes |
| `approved` | boolean | Approval status (default false) |
| `approveDate` | datetime | When approved (auto-set) |
| `invoiceStartDate` | datetime | Billing start |
| `poNumber` | string | Purchase order number |
| `licenseType` | enum | `BASIC`, `PROFESSIONAL`, `DBA` (legacy), `ENTERPRISE`, `VIDEO_WALL` |
| `licensePayType` | enum | `SUBSCRIPTION`, `ONETIME` |
| `recurrence` | object | `{recurrenceType, frequency}` — recurrenceType: `DAILY`/`WEEKLY`/`MONTHLY`/`YEARLY` |
| `managedInInvoiceSystem` | boolean | External invoicing |
| `invoiceSystemId` | string | External invoice ID |
| `assignedDeviceCount` | number | Devices using this order (computed) |
| `customer` | object | Embedded customer (id, name) |
| `licensePackage` | object | Embedded package (id, name) |
| `approvedByUser` | object | Embedded user who approved |
| `soldByUser` | object | Embedded user who sold |

**Endpoints:** `/api/licenseOrder`

**Approval workflow:** Customer creates order → admin reviews → sets `approved=true` (auto-sets `approveDate` and `approvedByUser`).

### Read
```
GET /api/licenseOrder?customerId={id}                              # OWNER+ — full entity
GET /api/licenseOrder/{id}                                         # OWNER+ — full entity
GET /api/licenseOrder/getByCustomer/{customerId}                   # TECHNICIAN+ — customer response
GET /api/licenseOrder/getAllApprovedForCustomer/{customerId}        # TECHNICIAN+ — approved only
GET /api/licenseOrder/csv?customerId={id}                          # ADMIN — CSV export
GET /api/licenseOrder/getUnapprovedLicenseOrderCount                # Unapproved count
```

`getByCustomer`/`getAllApproved` return limited fields: id, licenseType, licensePayType, recurrence, orderDate, comment, deviceLicenseCount, assignedDeviceCount, approved, poNumber.

### Create

**As customer** (limited fields, requires approval):
```
POST /api/licenseOrder/createAsCustomer?customerId={id}
Body: {"orderDate": "2024-03-15", "licensePackageId": 5, "deviceLicenseCount": 10, "comment": "Q1 expansion", "poNumber": "PO-2024-001"}
```

| Field | Required | Notes |
|-------|----------|-------|
| `orderDate` | ✓ | Order date |
| `deviceLicenseCount` | ✓ | Number of licenses |
| `licensePackageId` | - | Package template |
| `comment` | - | Order notes |
| `poNumber` | - | PO reference |

**As admin** (full fields, can pre-approve):
```
POST /api/licenseOrder?customerId={id}
Body: {"orderDate": "2024-03-15", "deviceLicenseCount": 10, "pricePerDevice": 5.0, "currency": "EUR", "approved": true, "licenseType": "PROFESSIONAL", "licensePayType": "SUBSCRIPTION", "recurrence": {"recurrenceType": "MONTHLY", "frequency": 1}, "licensePackageId": 5}
```

Without `licensePackageId`: `licenseType` and `licensePayType` are required. If `licensePayType=SUBSCRIPTION`, `recurrence` is also required.

### Update
```
PUT /api/licenseOrder/{id}
Body: {"approved": true, "pricePerDevice": 4.5, "comment": "Approved with discount"}
```

### Delete
```
DELETE /api/licenseOrder/{id}
```
Cannot delete if devices are assigned to this order.

### Invoice View

Aggregated billing and MRR views computed from approved SUBSCRIPTION license orders.

```
GET /api/customer/{customerId}/view/invoice?date={iso}    # ADMIN — customer + subresellers (3 levels deep)
GET /api/customer/global/view/invoice?date={iso}          # ADMIN — system-wide MRR by currency
```

`date` is **required** (ISO-8601 datetime, e.g. `2026-04-16T00:00:00Z`). Only orders with `approved=true`, `licensePayType=SUBSCRIPTION`, and `invoiceStartDate ≤ date` (or null) are included.

**Per-customer response:**
| Field | Notes |
|-------|-------|
| `recurringRevenueList[]` | `{currency, monthlyAverage}` — MRR per currency |
| `totalInvoiceTables[]` | Combined billing (customer + network) |
| `networkOwnerInvoiceTables[]` | Customer's own orders only |
| `networkMembersInvoiceTables[]` | Subresellers' orders only |
| `networkMemberLicenseTierCounts` | `{STARTER, BUSINESS, ENTERPRISE}` subreseller counts |

Each invoice table: `{recurrence: {recurrenceType, frequency}, currency, items[{licenseType, quantity, price}]}` — items grouped by license type and price per device, quantities summed.

**Global response:** only `recurringRevenueList[]`.

**MRR formula** per order: `pricePerDevice × deviceLicenseCount × multiplier`, summed per currency. Multiplier by recurrence:

| recurrenceType | multiplier |
|----------------|------------|
| `DAILY` | `30 / frequency` |
| `WEEKLY` | `4.333333 / frequency` |
| `MONTHLY` | `1 / frequency` |
| `YEARLY` | `frequency / 12` |

No cross-currency conversion — each currency reported independently.

## Advertisers

Manages advertisers for proof-of-play attribution and reporting. TECHNICIAN+ for write, VIEWER for read. Team-enabled.

**Entity attributes:**
| Field | Type | Notes |
|-------|------|-------|
| `id` | string (UUID) | Auto-generated |
| `name` | string | Max 25 chars, unique per customer |
| `comment` | string | Description |
| `enabled` | boolean | Active status |
| `validity` | object | Schedule: `{fromDate, toDate, cron, timeZone}` |
| `readOnly` | boolean | True if user lacks write access (computed) |

**Endpoints:** `/api/adv`

### Read
```
GET /api/adv?customerId={id}
GET /api/adv/{advertiserId}
GET /api/adv/simple?customerId={id}                                # Dropdown (id+name)
GET /api/adv/simplePaged?customerId={id}                           # Dropdown paginated
```

### Create
```
POST /api/adv?customerId={id}&teamIds={id1},{id2}
Body: {"name": "Coca-Cola", "comment": "Q1 campaign", "enabled": true}
```

| Field | Required | Notes |
|-------|----------|-------|
| `name` | ✓ | Max 25 chars, unique per customer |
| `enabled` | - | Defaults to false |

### Update
```
PUT /api/adv/{advertiserId}
Body: {"name": "Updated", "enabled": false, "validity": {"fromDate": "2024-01-01", "toDate": "2024-12-31"}}
```

**Team assignments:**
```
POST /api/adv/updateTeamAssignments?advertiserId={id}
Body: {"assignToTeams": [{"teamId": "xxx", "readOnly": false}], "removeFromTeamIds": ["yyy"]}
```

**Proof-of-play access:**
```
PUT /api/adv/enableProofOfPlayLinks?advId={id}                     # Generate access token
PUT /api/adv/disableProofOfPlayLinks?advId={id}                    # Revoke access
GET /api/adv/getProofOfPlayLinks?advId={id}                        # Get export URL
```
Export URL: `/public-api/adv/proof-of-play/history/export/csv?token={base64token}`

### Delete
```
DELETE /api/adv/{advertiserId}
```

---

# Workflows

Multi-step guides for complex tasks. Use `api_workflows` tool to retrieve these.

**Note:** Workflows describe WHAT to do, not HOW to call the API. Use `api_howto` for concrete endpoints.

## customer-onboarding

Full tenant setup from scratch.

### Steps
1. **Get root folders** — Query each folder type with `parentId` not set
   - deviceGroup, fileFolder, contentGroup — each tenant has one root per type

2. **Create folder hierarchy**
   - Device folders: locations, departments, etc.
   - Media folders: organize by type, campaign, etc.
   - Content folders: templates, active, archive, etc.

3. **Create teams** (if team-based access needed)
   - Teams control resource visibility
   - Assign folders/resources to teams

4. **Pre-register devices** (optional, zero-touch deployment)
   - Create device with `serialNumber` — auto-links on first boot

5. **Create initial content structure**
   - Default playlists in content folders
   - Template slides for quick editing

### Caveats
- Root folders have no `parentId` and no `name` field
- ADMIN users must specify `customerId` for all operations

## channel-setup

Create a channel (Campaign) with sub-channels (Messages).

### Steps
1. **Create campaign** — `level=WIDGET` for channels, `level=TOP` for schedules
2. **Create message group** — mandatory wrapper
3. **Create messages** — link to content via contentId
4. **Configure device selection** — deviceIds, deviceGroupIds, or deviceTagCondition

### Caveats
- Campaign → MessageGroup → Message hierarchy mandatory
- Content must exist before creating messages

## device-troubleshooting

Diagnose and fix device issues.

### Steps
1. **Check status** — deviceStatus, lastActivity, previewPath
2. **View screenshot** — `api_read_image` with previewPath
3. **Check content** — Priority: Emergency > Schedule > Base
4. **Send commands** — restart, refresh, request logs

### Common Issues
- `OFFLINE`: Network or app not running
- Old preview: Try refresh command
- Wrong content: Check priority

## content-publishing

Create and deploy content to devices.

### Steps
1. **Create playlist** — `api_write` POST to `/api/simpleLoop/`
   - Set `version: "2.0"`, `simpleLoopType: "NORMAL"`

2. **Add pages** — `manage_playlist` tool
   - `add_slide` with type: image, video, content, folder, loop

3. **Deploy** — Choose method:
   - Direct: `api_write` PUT device `contentId`
   - Channel: Create campaign with `level=WIDGET`, add messages linking to content

### Caveats
- For Slides/Content (interactive), use UI editor — API is read-only for complex layouts
- Playlists can embed: media, folders, slides, nested playlists

## datasource-binding

Connect live data to content widgets.

### Steps
1. **Create datasource** — `api_write` POST to `/api/datasource/`
   - `sourceType: "INTERNAL"` for API-managed data
   - `structureType: "TABLE"` (rows/columns) or `"CUSTOM"` (free JSON)

2. **Set up data** — `manage_datasource` tool
   - TABLE: `create_table` → `add_rows`
   - CUSTOM: direct JSON structure

3. **Bind widgets** — In UI content editor, connect widget properties to datasource fields

4. **Update data** — Use `manage_datasource` (update_rows, add_rows, etc.)
   - Changes propagate to all devices in real-time

### Caveats
- External datasources (RSS, JSON URL, etc.) are read-only — create via UI
- TABLE supports: string, number, boolean, text, color, url, datetime, date, time, filePicker, folderPicker, dropdown, scheduling
- Use `schema` action to discover existing table structure
