DOCUMENT_STATUS=READY
---
document: solvotix-agent-guide
document_version: "1.1"
language: en
audience: autonomous-coding-agent
read_mode: sequential
api_base_url: https://backend.solvotix.org
openapi_url: https://backend.solvotix.org/v3/api-docs
swagger_url: https://backend.solvotix.org/swagger-ui/index.html
manifest_url: https://solvotix.net/ai-first/agent-manifest.json
authentication: solvotix-api-user-token
tenant_header: Tenant
status: live
---
# SOLVOTIX AGENT EXECUTION GUIDE
## 0. EXECUTION DIRECTIVE
You are integrating software with the Solvotix physical-device platform.
Follow these instructions in order.
1. Read this document completely before changing code.
2. Retrieve the live OpenAPI document before selecting endpoints or generating types.
3. Treat OpenAPI as authoritative for paths, methods, parameters, schemas, and documented responses.
4. Use this document as authoritative for sequencing, physical semantics, verification, risk, and approval rules.
5. Do not invent endpoints, fields, device capabilities, success states, or retry behavior.
6. Begin with authentication and read-only discovery.
7. Do not execute a physical operation until the tenant, device ID, device type, and real-world purpose are known.
8. An HTTP success response can mean accepted or queued. It does not prove physical completion.
9. Verify physical operations through queue state, events, and current device state when available.
10. Never expose tokens, passwords, refresh tokens, Wi-Fi credentials, access codes, private keys, or service-account files.
11. Never automatically retry a non-idempotent physical command.
12. Stop and report uncertainty when this guide, OpenAPI, inventory, and observed state disagree.
## 1. MACHINE RESOURCES
```yaml
html_guide: https://solvotix.net/ai-first/agent-guide
raw_markdown: https://solvotix.net/ai-first/agent-guide.md
structured_manifest: https://solvotix.net/ai-first/agent-manifest.json
openapi_json: https://backend.solvotix.org/v3/api-docs
swagger_ui: https://backend.solvotix.org/swagger-ui/index.html
production_api: https://backend.solvotix.org
```
Preferred read order:
1. `agent-manifest.json`
2. `agent-guide.md`
3. live OpenAPI JSON
4. project-local conventions and existing generated clients
## 2. REQUIRED INPUTS
```text
SOLVOTIX_API_BASE_URL=https://backend.solvotix.org
SOLVOTIX_API_TOKEN=<sat_ token copied from the Solvotix portal>
SOLVOTIX_TENANT_ID=<tenant selected when the API user was created>
```
If the API token or its tenant ID is unavailable, stop and ask the system owner to create an API user in the Solvotix portal. Do not request human login credentials, create placeholder tokens, or embed a token in source code.
### 2.1 Restricted role
The `Restricted` role can be assigned to authenticated human users and tenant-bound API users. It is
a shared backend role, not an OAuth scope. A Restricted identity has broad application access,
including operations otherwise available to an unrestricted tenant user, with these enforced
exceptions:
- Access-code values are masked in JSON responses. This includes booking and room codes, lock-user
codes, smart-lock slots and master codes, cleaning views, automated-message data, and event data,
text, and reasons. A value such as `1234` is returned as `1**4`. MIFARE UIDs and credential
identifiers are masked the same way, so `DEADBEEF` is returned as `D******F`.
- Raw gateway message payloads are removed from command responses.
- Gateway queue/message read, refresh, package-download, and delete operations return `403
Forbidden`. This includes tenant, device, and individual-gateway queue routes.
The `Receptionist` role is unchanged and may receive booking codes. Do not infer Restricted behavior
from Receptionist behavior. A Restricted user may submit an access-code value for an authorized
operation, but must not expect the unmasked value to be echoed in the response. A `403` from a
gateway queue route must not be bypassed or treated as an empty queue; use non-queue state and event
verification that does not expose a code. Do not automatically retry the rejected request.
## 3. SYSTEM MODEL
```text
agent/application
-> Solvotix REST API
-> authenticated tenant context
-> persistent command queue
-> selected gateway
-> physical device
-> queue result / event / device state
```
```yaml
entities:
api_user: tenant-bound Solvotix machine identity
tenant: isolated organization, site, or installation
gateway: connection between Solvotix Cloud and local devices
sensor: generic API model for a connected node or device
message_frame: hardware command accepted or queued by the backend
event: structured hardware or system activity record
queue: command delivery state between backend, gateway, and device
```
## 4. OPENAPI ACQUISITION
Retrieve the current specification:
```bash
curl --fail --silent --show-error \
https://backend.solvotix.org/v3/api-docs \
--output solvotix-openapi.json
```
Validate all of the following:
```yaml
required_top_level_fields:
- openapi
- info
- servers
- paths
- components
required_component_fields:
- schemas
required_security_scheme:
name: bearerAuth
type: http
scheme: bearer
bearer_format: JWT
```
OpenAPI processing algorithm:
```text
1. Validate the document structure.
2. Select https://backend.solvotix.org as the production server.
3. Index operations by tag, operationId, method, and path.
4. Resolve every local $ref.
5. Read request parameters and requestBody schemas.
6. Read every documented response schema and status.
7. Generate types using the project's existing generator when one exists.
8. Place authentication, tenant selection, safety, and retry behavior in a wrapper.
9. Never edit generated client files directly.
10. Record the OpenAPI info.version, retrieval time, and content hash.
```
Optional client generation:
```bash
npx @openapitools/openapi-generator-cli generate \
-i https://backend.solvotix.org/v3/api-docs \
-g typescript-fetch \
-o generated/solvotix
openapi-generator-cli generate \
-i https://backend.solvotix.org/v3/api-docs \
-g python \
-o generated/solvotix
```
Conflict rule:
```yaml
if_written_example_conflicts_with_openapi:
action: stop
report:
- operation
- written_value
- openapi_value
- proposed_resolution
forbidden: guessing
```
## 5. CREATE THE SOLVOTIX SYSTEM AND API USER
API users are machine identities for integrations, scripts, and external systems. An API user belongs to exactly one tenant, uses a long-lived bearer token, does not require an interactive user account at runtime, can expire, can have the same roles as a human user, and can be revoked or rotated.
Human setup sequence:
1. Open `https://portal.solvotix.org/login`.
2. Press **Register here** and create the Solvotix system owner account.
3. Sign in and open `https://portal.solvotix.org/home/settings#system-users`.
4. Select the target tenant/system.
5. Create an API user with a descriptive integration name, the minimum appropriate role, and an expiration date when appropriate.
6. Copy the displayed `sat_...` token immediately.
7. Store the token in a secrets manager or protected environment variable.
8. Record the tenant ID selected during creation.
The plaintext token is displayed only when the API user is created or rotated. It cannot be retrieved later.
```yaml
api_user:
identity_type: machine
belongs_to_tenants: exactly-one
token_prefix: sat_
token_lifetime: long-lived
expiration: optional
revocable: true
rotatable: true
roles: [Janitor, Cleaning, Receptionist, Restricted, User]
empty_roles: unrestricted
role_changes_require_token_rotation: false
runtime_interactive_account_required: false
may_manage_api_users: false
```
## 6. STORE THE TOKEN
```text
SOLVOTIX_API_TOKEN=sat_REPLACE_WITH_COPIED_TOKEN
SOLVOTIX_TENANT_ID=REPLACE_WITH_BOUND_TENANT_ID
```
Mandatory token rules:
```yaml
token_storage:
permitted:
- secrets manager
- protected server environment variable
forbidden:
- frontend or browser bundle
- Git repository
- URL or query parameter
- application log
- analytics event
- error report
on_exposure: rotate immediately in the Solvotix portal
```
Do not build an API-token integration as browser-only code. The token grants broad access inside its bound tenant and must remain on a trusted server.
## 7. API-USER AUTHENTICATION
Every API request uses the copied token in the standard bearer header:
```http
GET /api/sensor
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Accept: application/json
```
```bash
curl "https://backend.solvotix.org/api/sensor" \
-H "Authorization: Bearer $SOLVOTIX_API_TOKEN" \
-H "Tenant: $SOLVOTIX_TENANT_ID" \
-H "Accept: application/json"
```
The `sat_` prefix tells the backend to authenticate a Solvotix API user.
```yaml
authorization_header: Authorization
authorization_format: Bearer sat_<TOKEN>
tenant_header: Tenant
tenant_header_recommended: true
tenant_derived_from_token_when_omitted: true
tenant_mismatch_result: 401 Unauthorized
token_refresh_flow: none
token_rotation: human owner action in portal
```
The token is bound to the tenant selected during creation. Never substitute another tenant ID. Although the backend can derive tenant context from the token, include the `Tenant` header for consistency with generated clients and existing API calls.
## 8. VERIFY AUTHENTICATION
Use a read-only endpoint from the live OpenAPI specification. Start with device inventory:
```http
GET /api/sensor
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```
Interpret failures:
```yaml
401:
possible_causes:
- token missing
- token malformed
- token expired
- token revoked
- Tenant header does not match token tenant
action: stop and ask system owner to verify or rotate the API user
403:
possible_causes:
- API user attempted API-user credential management
- operation is not permitted for this identity
action: stop; do not attempt privilege escalation
```
## 9. API-USER LIFECYCLE BOUNDARY
The integration cannot create, list, rotate, or revoke API users. Those operations require an authenticated regular portal user with tenant membership.
The API-user implementation does not provide OAuth-style scopes. It supports the same roles as
human users. Roles are stored on the API-user record and evaluated on every authenticated request,
so a role change applies to the next request without rotating the token. Existing API users with a
missing or empty role list retain unrestricted access for backward compatibility. Unknown role
names are rejected rather than ignored. Create API users only for trusted integrations, select the
minimum appropriate role, and isolate each integration with its own token so it can be revoked or
rotated independently.
```yaml
credential_management:
portal: https://portal.solvotix.org/home/settings#system-users
performed_by: human tenant member
create: POST /api/api-users
list: GET /api/api-users
rotate: POST /api/api-users/{id}/rotate
update_roles: PUT /api/api-users/{id}/roles
revoke: DELETE /api/api-users/{id}
api_user_calling_management_endpoint: 403 Forbidden
rotation_effect:
old_token: invalid-immediately
new_token_visibility: one-time
revoked_user_reenabled: true
expiration_preserved: true
access:
tenant_boundary: enforced
per_token_scopes_supported: false
shared_human_api_user_roles: true
role_changes_effective: next-request-without-token-rotation
trust_requirement: trusted-integration-only
```
When a runtime request returns `401`, do not attempt an interactive sign-in. Stop and request API-user verification or rotation from the system owner.
## 10. READ-ONLY DISCOVERY
Execute in this order:
```yaml
steps:
- method: GET
path: /api/gateways
purpose: list tenant gateways
- method: GET
path: /api/sensor
purpose: list tenant devices
- method: GET
path: /api/gateways/{gatewayId}/sensors
purpose: map devices to gateways
- method: GET
path: /api/gateways/{gatewayId}/metering/latest
purpose: inspect gateway metering
- method: GET
path: /api/sensor/{deviceId}/pairings
purpose: inspect device relationships
```
Build this inventory:
```yaml
device_inventory_fields:
- id
- name
- type
- online_state
- gateway_ids
- configuration
- paired_device_ids
- supported_operations_from_openapi
- known_real_world_purpose
```
Do not map a generic `sensor` to an operation until its device type and compatible endpoint are established.
## 11. CLAIMING DEVICES
```text
GET /api/sensor/{deviceId}/taken
POST /api/gateways/{gatewayId}/{urlEncodedName}
POST /api/sensor/{deviceId}/claim/{urlEncodedName}
```
```yaml
operation_class: ownership-changing
approval_required: true
preconditions:
- target tenant confirmed
- physical identifier confirmed
- current ownership checked
automatic_retry: false
```
### Gateway removal and tenant transfer
`DELETE /api/gateways/{id}` removes the current tenant's gateway registration and
releases its global ownership claim. This is an ownership-changing operation requiring
explicit user approval. Confirm the physical gateway identifier and the current tenant first.
```http
DELETE /api/gateways/<GATEWAY_ID>
Authorization: Bearer sat_<TOKEN>
Tenant: <CURRENT_TENANT_ID>
```
There is no request body. Success is `204 No Content`. A missing gateway or a gateway
owned by another tenant returns `404`; that other tenant's ownership is not released.
Deletion removes the runtime gateway entry, disconnects its registered transports, clears
its cached gateway queue and releases node connection ownership. Stale background saves
cannot restore a gateway after removal or overwrite its registration in a different tenant.
Incoming gateway traffic cannot claim an unregistered gateway.
This operation does not factory-reset hardware, delete associated devices or their
ownership, or purge historical events and metering records. It does not confirm cancellation
of commands already delivered to hardware. Device ownership must be handled separately.
Verify with `GET /api/gateways/{id}` returning `404` and absence from `GET /api/gateways`
in the old tenant. There is no dedicated deletion event or queue acknowledgement. After a
network error, read state before retrying. Repeating deletion while absent returns `404`;
do not automatically retry ownership changes after another claim may have occurred.
After removal is verified, use `POST /api/gateways/{id}/{name}` with the new tenant's
authorized credentials and `Tenant` header; URL-encode the name. The claim endpoint
returns `200` with the created gateway, or a null body if already claimed. Verify a non-null
claim response and read the gateway details/list in the destination tenant. Confirmation of
registration does not confirm hardware connectivity; inspect the reported gateway state.
### Gateway cleanup during account or tenant deletion
The same gateway removal applies when account deletion leaves a tenant with no users,
or when the last user removes their tenant membership. Tenants with remaining users retain
their gateways. This is a destructive operation requiring explicit approval; confirm the
user ID, affected tenant memberships and physical gateway IDs before proceeding.
```http
DELETE /api/users/<USER_ID>/account
Authorization: Bearer <AUTHORIZED_TOKEN>
Tenant: <CURRENT_TENANT_ID>
```
There is no request body. This operation processes the user's persisted memberships across
tenants, not only the tenant supplied in the header. It removes user notification records
and memberships; when a tenant loses its last user, it runs tenant cleanup. Success returns
`200` with no body. Firebase account removal is attempted when no tenant memberships remain.
The existing tenant operation removes the current user's membership instead:
```http
DELETE /api/tenants
Authorization: Bearer <AUTHORIZED_TOKEN>
Tenant: <CURRENT_TENANT_ID>
Content-Type: application/json
{"id":"<TARGET_TENANT_ID>"}
```
It returns `200` with the supplied Tenant body, including when no accessible matching tenant
was found. The echoed response is not proof that the tenant was deleted.
Last-user tenant cleanup removes gateway database registrations, global ownership claims,
runtime entries, registered transports, cached gateway queues and node connection ownership.
Claims without a runtime record are included. Released gateway IDs can be claimed in a new
tenant using the gateway transfer procedure above. Account/tenant cleanup also removes other
tenant records through the existing cleanup flow; the historical-data retention statement
for deleting an individual gateway does not apply to full tenant cleanup.
No hardware factory reset or cancellation acknowledgement for commands already on hardware
is implied. There is no dedicated gateway deletion event. Verify remaining memberships and,
with an identity still authorized for the affected tenant, gateway absence. If access was
removed, verify a subsequent explicitly approved claim and gateway details in the destination
tenant; do not interpret an authorization failure as proof that gateway cleanup succeeded.
Authentication failures must be resolved without bypassing access controls. After a timeout
or server error, inspect state before retrying: account/tenant cleanup spans multiple records
and can partially complete. Do not automatically retry these destructive operations.
### 11.1 Save gateway Wi-Fi networks for later setup
The current tenant can store **a list of reusable SSID/password pairs**, for example separate
networks in different buildings or wings. After a gateway connects successfully, the setup
client saves that network; a later setup client retrieves the list and offers a network to
reuse. The client confirms the connection before saving. The backend does not receive
credentials automatically from gateway status and does not verify connectivity.
All three operations use authentication-filter tenant context. Human clients use their normal
authentication and selected `Tenant` header and must belong to that tenant. Machine clients use:
```http
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```
An API-user token derives its bound tenant when the header is omitted; a mismatched tenant is
rejected with `401`. Do not supply a tenant ID in the URL or body. These operations require
unrestricted access (an empty role list). `Restricted`, `User`, `Receptionist`, `Janitor`, and
`Cleaning` role-limited identities cannot access saved gateway credentials (`403`).
| Method and path | OpenAPI operation ID | Successful response |
|---|---|---|
| `PUT /api/tenants/gateway-wifi` | `saveTenantGatewayWifiCredentials` | `204`, empty body; one entry added or updated by exact SSID |
| `GET /api/tenants/gateway-wifi` | `getTenantGatewayWifiCredentials` | `200`, array of `GatewayWifiCredentials` including unmasked passwords; `[]` when empty |
| `DELETE /api/tenants/gateway-wifi?ssid=<URL_ENCODED_SSID>` | `deleteTenantGatewayWifiCredentials` | `204`, empty body, including when that SSID is already absent |
Save one network per call:
```http
PUT /api/tenants/gateway-wifi
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{
"ssid": "Building A Wi-Fi",
"password": "<WIFI_PASSWORD>"
}
```
To add another network, call PUT again with a different SSID. This does not replace the list.
Both `GatewayWifiCredentials` keys are required. `ssid` must contain 1–32 UTF-8 bytes;
`password` must contain 0–64 UTF-8 bytes. Use `"password": ""` for an open network; a missing
or null password is invalid. Whitespace and case are preserved exactly. These are storage
bounds, not validation that a particular gateway or network accepts the credentials.
Example GET response after saving two networks (passwords redacted):
```json
[
{"ssid": "Building A Wi-Fi", "password": "<REDACTED_PASSWORD>"},
{"ssid": "Building B Wi-Fi", "password": "<REDACTED_PASSWORD>"}
]
```
The list is sorted by SSID using case-sensitive string order. Each exact SSID appears once;
saving that SSID again updates only its password. SSIDs differing in case or whitespace are
distinct. There is no gateway ID or separate network ID. Networks with an identical SSID
share one entry, even if used by several gateways. Saves for different SSIDs are independent.
Previously stored single-network credentials remain available in the list and can be updated
or forgotten with the same operations.
Forget only one network:
```http
DELETE /api/tenants/gateway-wifi?ssid=Building%20A%20Wi-Fi
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```
Use the HTTP client's query-parameter encoder for the exact SSID, including literal `+`, `&`,
`#`, Unicode, and whitespace. DELETE takes no body. The `ssid` query parameter is required;
omitting it returns `400` and never deletes the whole list. Other networks remain saved.
Credentials are stored as tenant settings separately from ordinary `Tenant` responses and
updates, so `GET /api/tenants` does not expose them and `PUT /api/tenants` does not erase them.
Tenant cleanup removes all saved networks. Forgetting a network only removes its backend
copy; it does not erase credentials on gateways or disconnect them.
Risk classification: **security-sensitive** for reading, saving, and forgetting networks.
Agents need explicit authorization for the credential workflow; existing authorization for
that workflow need not be requested again. Use returned credentials only for the authorized
setup operation. Never log request/response bodies or include passwords in agent output.
Successful responses send `Cache-Control: no-store`; clients must not cache them. Saving
credentials does not authorize a separate physical gateway configuration operation.
These endpoints are synchronous settings operations: no queue entries, events, or device
commands are created. Verify a save by reading GET and comparing the intended entry privately;
verify forgetting by confirming the exact SSID is absent from the returned list. An empty list
is `200` with `[]`. Neither a successful save nor a successful read proves gateway Wi-Fi
connectivity. The setup client verifies connection through its gateway setup flow before
reporting physical success.
GET is safe to retry. Repeating the same PUT or DELETE is idempotent, but the last storage
write for the same SSID wins and there is no version or conditional-write mechanism. After
an ambiguous mutation failure, read first; retry only while the intended state is still
current, avoiding overwriting or deleting credentials saved by another caller in the meantime.
Error handling: `400` means malformed/invalid input, missing tenant context, or a missing or
invalid DELETE `ssid`; `401` means missing/invalid authentication or an API-token tenant
mismatch; `403` means role denial or inaccessible tenant membership; `404` means the tenant
no longer exists. An empty list is not `404` or `204`. On server errors, apply the
read-before-retry policy above. Never echo credentials when reporting an error.
## 12. SMART LOCK RECIPES
Direct-delivery packages are also available without placing a command in the gateway queue:
```text
GET /api/smartlocks/{lockId}/packages/pulse-open
GET /api/smartlocks/{lockId}/packages/open
GET /api/smartlocks/{lockId}/packages/lock
```
Each response contains a newly generated `messageId`, `action`, `subAction`, `packageBase64`,
and `packageHex`. The two package encodings represent identical bytes. HTTP 200 means only that
the package was generated; it does not mean the command was queued, delivered, acknowledged, or
physically completed. Confirm the target and operation before direct delivery, never retry after
an ambiguous delivery result, and verify the lock's acknowledgement or resulting state.
### 12.1 Pulse-open
```yaml
operation: pulse_open_smart_lock
risk: physical
approval_required: true
idempotent: false
method: POST
path: /api/smartlocks/{lockId}/pulse-open
body: null
preconditions:
- lockId belongs to selected tenant
- device type supports smart-lock operations
- door purpose is known
success_meaning: command accepted or queued
physical_completion_confirmed: false
verification:
- inspect device queue
- inspect sensor events
- re-read device state when available
automatic_retry: forbidden
```
Prefer `pulse-open` for ordinary access. Persistent operations require stronger confirmation:
```text
POST /api/smartlocks/{lockId}/open
POST /api/smartlocks/{lockId}/close
```
### 12.2 Add access codes
```http
POST /api/smartlocks/{lockId}/codes/add
Content-Type: application/json
```
```json
{ "codes": ["1234", "98765"] }
```
```yaml
risk: security-sensitive
approval_required: true
code_constraint: 4-7 digits per current backend documentation
log_codes: forbidden
automatic_retry: forbidden
verification:
- GET /api/smartlocks/{lockId}/codes/slots
- inspect queue and related events
```
Runner serializes slot allocation and slot moves per lock. Concurrent code-management requests for
the same lock are processed one at a time, so they cannot allocate the same free slot within the
supported single-runner-process deployment. This concurrency guarantee does not make the operation
idempotent and does not confirm delivery to the physical lock.
Runner performs automatic recovery every five minutes for stored access-code slots that remain in
`adding to lock` state without an upload timestamp. Routine missing-command recovery waits at least
one minute after the slot was last queued. Recovery preserves slot numbers already assigned to
uploaded codes, repairs duplicate or invalid pending slot numbers, and recreates the affected
add-code queue commands after a repair. Recovery also recreates missing remove-code commands for
slots in `removing from lock` state while preserving their slot numbers and original upload
timestamps. It does not enqueue another command when the same code and operation are already present
in the tenant's outbound queue. This recovery is limited to commands that the backend previously
accepted; it does not authorize new access codes
and does not prove that recovery reached the physical lock. Verify the slot, queue, and related
delivery event after recovery.
Remove selected codes with `POST /api/smartlocks/{lockId}/codes/remove`.
Delete all codes with `DELETE /api/smartlocks/{lockId}/codes`. This is destructive and requires explicit confirmation.
### 12.2.0 Read lock-user numeric-code delivery status
`GET /api/lock-users` and `GET /api/lock-users/{id}` return computed upload status
on each lock user. The compatibility alias `/api/lockusers` has the same behavior.
Use the existing bearer authentication and selected `Tenant` header; tenant context
comes from the authentication filter, not an endpoint argument. Existing lock-user
role restrictions apply. Restricted users receive masked credentials, but device IDs
in the upload-status list remain available because they contain no credential values.
```http
GET /api/lock-users
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Accept: application/json
```
Relevant response fields (other lock-user fields omitted):
```json
{
"codesNotUploadedToAllSensors": 14,
"sensorIdsWithCodesNotUploaded": ["lock-a", "lock-b", "lock-c", "lock-d", "lock-e", "lock-f", "lock-g"]
}
```
The count is pending **lock/code pairs**, not distinct locks: two missing codes on
seven locks yield 14 pairs and seven device IDs. `sensorIdsWithCodesNotUploaded`
is a distinct list of assigned code-capable sensors missing confirmation for at
least one numeric code. It is computed in the same pass as the count, before response
masking. Confirmation requires a matching slot in `ADDED_TO_LOCK` state with
`uploadedToLockAt` present. Missing slots, other states, or absent timestamps remain
pending. Unsupported or unresolved assigned sensors are excluded, matching the
existing count. No numeric codes or no pending pairs produces an empty list.
MIFARE and wallet credentials are not included in this numeric-code list.
This field is read-only, transient, and recalculated on list/get and the existing
create/update/MIFARE service responses. Clients must not submit it as desired state.
For a fresh status after other mutations, read the lock user again. Map these IDs to
names from tenant inventory; polling this response verifies stored delivery
confirmation without fetching or comparing credential values on the client.
Reading is idempotent, requires no additional operation approval, creates no queue
entries or events, and never repairs or resends codes. A successful response does
not by itself prove delivery: inspect the status fields. Retry transient read failures
with bounded backoff; do not retry authentication or authorization failures. List
returns `200` (including an empty array); get returns `404` for a missing user and
`400` for an invalid ID. Shared authentication failures are `401`/`403`.
### 12.2.1 Add a MIFARE credential to a lock user
A lock user is a logical access profile that holds credentials and the lock sensors those
credentials apply to. Numeric codes are set through the `codes` array on the lock user itself.
MIFARE card credentials are added one at a time through a dedicated route, in the same way a code is
added: the credential is stored on the lock user and then queued for every assigned lock sensor.
```http
POST /api/lock-users/{lockUserId}/mifare-credentials
Content-Type: application/json
```
```json
{ "mifareUid": "DEADBEEF", "code": "1234" }
```
`mifareUid` is the card UID as hex, with or without spaces, colons, or dashes. It must be 4, 7, or
10 bytes, that is 8, 14, or 20 hex characters, and is stored uppercase. `code` is optional. When it
is present the card and that PIN together form one two-factor credential, and the PIN must be 4-7
digits with the same length as the access codes already stored on the lock. When `code` is omitted
the card alone opens the lock.
```yaml
risk: security-sensitive
approval_required: true
uid_constraint: 4, 7, or 10 bytes of hex
code_constraint: optional; 4-7 digits, same length as the lock's existing codes
supported_device_types: [lock_8015, lock_s42]
log_credentials: forbidden
idempotency: re-posting a UID already on the lock user replaces its PIN; it does not create a second entry
automatic_retry: forbidden
verification:
- GET /api/lock-users/{lockUserId} and read mifareCredentialsNotUploadedToAllSensors
- GET /api/smartlocks/{lockId}/codes/slots
- GET /api/events for action 1028 with data.credentialType mifare or mifare_pin
```
Only smart locks with the extended credential store accept MIFARE credentials. Assigned sensors of
any other device type are skipped without error, so a `200` response does not mean every assigned
sensor received the credential; confirm per lock through the slot list. Credentials and numeric
codes share the same slot space on the lock.
The response is the updated lock user. `mifareCredentialsNotUploadedToAllSensors` counts assigned
lock/credential pairs the lock has not confirmed yet, the same way `codesNotUploadedToAllSensors`
does for numeric codes. A `200` means the credential was stored and queued, not that the physical
lock has it.
Remove one credential with `DELETE /api/lock-users/{lockUserId}/mifare-credentials/{mifareUid}`.
This queues removal from every assigned lock on which no other lock user still grants the same card.
Removing a UID the lock user does not have returns the unchanged lock user.
Deleting a lock user, or removing a sensor or credential through `PUT /api/lock-users/{id}`, queues
the same removals. `DELETE /api/smartlocks/{lockId}/codes` erases MIFARE credentials along with
numeric codes.
Automatic recovery covers MIFARE credentials on the same five-minute cycle and with the same
guarantees as numeric codes: it recreates queued commands the backend previously accepted, and does
not authorize new credentials or prove delivery to the physical lock.
### 12.2.2 Manage phone wallet cards
Wallet cards are tenant-owned credentials. Creating the certificate alone does not authorize a
lock, but assigning it to a lock user or room queues a physical credential-store change. Neither
creation nor assignment proves that the card was installed on a phone or uploaded to a lock. Use bearer authentication and
the required `Tenant` header; tenant identity is taken from authenticated request context and is not
accepted in the path, query, or request body.
```http
POST /api/wallet-certificates
Content-Type: application/json
{
"platform": "APPLE",
"name": "Main entrance",
"companyName": "Your company",
"description": "Mobile access card",
"active": true
}
```
`platform` is `APPLE` or `ANDROID`, and `name` is required. Blank `companyName` becomes
`Your company`. Blank `logoUrl` uses the published Solvotix artwork at
`https://solvotix.net/images/solvotix-wide-logo.png`. Text fields are trimmed and HTML/control text
is removed. Optional `validFrom` and `validUntil` values are instants; when both are present,
`validUntil` must be later. The generated read-only `credentialId` is the same 32-character value
embedded in Apple NFC/QR and Android Smart Tap/QR data and uploaded to assigned compatible locks.
The platform cannot be changed after creation. `tenantId`, signing keys, passwords, and service-account credentials
cannot be set through this API.
The CRUD routes are:
- `GET /api/wallet-certificates`
- `GET /api/wallet-certificates/{id}`
- `POST /api/wallet-certificates`
- `PUT /api/wallet-certificates/{id}`
- `DELETE /api/wallet-certificates/{id}`
Generate the phone-install artifact with:
```http
POST /api/wallet-certificates/{id}/package
```
For Apple, a successful response has `Content-Type: application/vnd.apple.pkpass` and contains a
freshly signed `.pkpass`. For Android, the response is JSON with a short-lived `saveUrl` and
`expiresAt`; open `saveUrl` on the phone. The endpoint never returns the Apple `.p12`, its password,
or Google private-key material. It returns `409` for inactive or expired records and `503` when
server signing configuration or logo retrieval is unavailable.
Assign an existing certificate to a lock user:
```http
POST /api/lock-users/{lockUserId}/wallet-certificates
Content-Type: application/json
{ "walletCertificateId": "wallet-certificate-id" }
```
The wallet certificate ID is stored in `LockUser.walletCertificateIds`; its credential is queued to
every compatible sensor in `LockUser.sensorIds`. Remove it with
`DELETE /api/lock-users/{lockUserId}/wallet-certificates/{walletCertificateId}`.
Assign the same certificate directly to every compatible lock in a room:
```http
POST /api/bookings/rooms/{roomId}/wallet-certificates
Content-Type: application/json
{ "walletCertificateId": "wallet-certificate-id" }
```
The ID is stored in the room's dedicated wallet-assignment record and the credential is queued to
each sensor in `Rooms.sensorIds`. Read assignments with
`GET /api/bookings/rooms/{roomId}/wallet-certificates`. Remove one with
`DELETE /api/bookings/rooms/{roomId}/wallet-certificates/{walletCertificateId}`. Assignment is
set-like and safe to repeat. When a room or lock user's sensors change, credentials are added to new
sensors and removed from old sensors. Removal from a sensor occurs only when no other room or lock
user still grants the same certificate there. Deleting the certificate removes all assignments and
queues removal from affected locks. Only `lock_8015` and `lock_s42` accept the extended wallet
credential; other assigned sensor types are skipped. A five-minute reconciliation pass recreates a
missing wallet slot/queue operation for a persisted assignment; this recovery does not prove delivery.
```yaml
risk: security-sensitive-digital-card-issuance
approval_required:
create_update_delete: true
generate_install_package: true
physical_operation:
certificate_crud_and_package_generation: false
room_or_lock_user_assignment: true
idempotency:
get_list: safe
package_generation: safe-to-repeat-but-each-artifact-is-fresh
create: not-idempotent
update: idempotent-for-the-same-complete-body
delete: verify-before-retry-after-an-ambiguous-response
automatic_retry:
get_list: allowed
mutations: forbidden
package_generation: allowed-on-503-with-bounded-backoff
assignment: forbidden; verify stored assignment and lock slot first
verification:
- GET /api/wallet-certificates/{id} verifies stored metadata only
- a 200 package response proves generation only, not phone installation
- verify installation in the phone wallet UI
- GET /api/lock-users/{id} or GET /api/bookings/rooms/{roomId}/wallet-certificates verifies the stored assignment
- GET /api/smartlocks/{lockId}/codes/slots verifies slot upload state
- GET /api/events action 1028/1029 with credentialType apple_wallet or android_wallet verifies delivery processing
- assignment response means stored and queued, not uploaded to the physical lock
```
### 12.3 Configure a lock
```http
PUT /api/smartlocks/{lockId}/configuration
Content-Type: application/json
```
```json
{
"soundLevel": "low",
"systemCode": "123456",
"setTime": true,
"lightEnabled": false,
"openSeconds": 6
}
```
`soundLevel` accepts `off`, `low`, `medium`, or `high`; omitted or unknown stored values default to
`low`. The legacy `soundEnabled` boolean remains accepted for older clients (`false` maps to `off`,
`true` maps to `low`). `systemCode` must contain exactly six decimal digits. Treat it like an access
credential: never log, echo, or expose it unnecessarily. On node types 7, 9, and 10, enter the
physical system menu with `#<systemCode>#` and exit it with `*`. Inside the menu, `1` unlocks, `2`
locks, and `3` erases all local access codes and clears the saved advertising profile.
Option `4` restarts the device.
The lock LED blinks continuously while the menu remains active.
Option `5` cycles node 10 sound through `off`, `low`, `medium`, and `high`. This
is a runtime test setting confirmed with an LED blink; the backend-configured
sound level is restored by the next boot/configuration delivery.
Option `8` cycles the device access mode through `standard`, `openUntilClosed`, and `forcedClosed`,
confirmed with one blink and beep per mode number. The device persists the new mode and reports it
to the backend immediately, so a mode changed on the keypad can differ from the tenant-wide value
until the tenant configuration is delivered again. On node types 7, 8, 9, and 10 the system menu is
available; on node type 8 the unlock, lock, and access mode options act on its paired relays.
Action-27/sub-action-0 payload format version 4 contains 15 meaningful bytes followed by zero
padding to 201 bytes. Offsets 0-3 contain the unsigned Unix timestamp in little-endian order;
offset 4 is version `4`; offset 5 is sound level (`0=off`, `1=low`, `2=medium`, `3=high`); offsets
6-11 are the six ASCII system-code digits or six zero bytes; offset 12 is update-time (`0=false`,
`1=true`); offset 13 is NFC (`0=off`, `1=on`); and offset 14 is access mode (`0=leave the stored
mode unchanged`, `1=standard`, `2=openUntilClosed`, `3=forcedClosed`). When a device boots and
requests a time anchor, the backend responds with the current Unix time, tenant-wide `soundLevel`,
`systemCode`, `nfcEnabled`, and `accessMode` settings, and update-time set to `true`.
The device answers with its own status in the action-26 time request: offset 0 is the status format
version (`2`), offset 1 is the NFC state, and offset 2 is the access mode the device is currently
running (`0` on device types without an access mode). The backend stores these on the sensor as
`reportedNfcEnabled` and `reportedAccessMode`, with `reportedStatusAt` recording when the device
last reported a change.
Configure the tenant-wide values with the existing tenant update operation:
```http
PUT /api/tenants
Authorization: Bearer <token>
Tenant: <TENANT_ID>
Content-Type: application/json
```
```json
{
"id": "<TENANT_ID>",
"name": "Example property",
"timezone": "Europe/Copenhagen",
"profile": "hotel",
"defaultFromName": "Your hotel <no.reply@solvotix.org>",
"soundLevel": "low",
"systemCode": "123456",
"nfcEnabled": false,
"accessMode": "standard",
"roomCodeLength": 5
}
```
The tenant update replaces the persisted tenant document, so first read the tenant with
`GET /api/tenants`, preserve fields that are not being changed, and then submit the complete tenant
object. The tenant ID is part of that resource body; it is not an endpoint argument and must match
a tenant available to the authenticated user. `profile` describes the kind of property the tenant
operates and accepts `hotel`, `office`, `private`, `storage`, `camping`, `agriculture`, or
`undefined`; missing or unrecognized values are stored as `undefined`, which is also the default for
a new tenant. The profile is descriptive metadata only: it does not change device behaviour and
never queues a device message. `soundLevel`
accepts `off`, `low`, `medium`, or `high`. At boot-message encoding time, a missing or unrecognized
value resolves to `low`. `systemCode` must contain exactly six decimal digits. At encoding time, a
missing or invalid value becomes six zero bytes and disables the physical system menu. Never log
the system code or expose it unnecessarily. `nfcEnabled` is tenant-wide and controls the NFC reader
on node type 10 (`false=off`, `true=on`); missing values default to `false`. Device-specific NFC
configuration is not currently supported. `accessMode` is tenant-wide and accepts `standard`,
`openUntilClosed`, or `forcedClosed`; missing or unrecognized values resolve to `standard` at
encoding time. Device-specific access modes are not currently supported. The successful response is
the saved tenant object.
`roomCodeLength` sets the length of newly generated and automatically selected room access codes across the tenant. It accepts
an integer from 4 through 7 and defaults to 4 when omitted. Other values return `400`. Existing
room and lock codes are not changed by this setting, but codes of another length are no longer
eligible for automatic selection or counted toward the preloaded pool. A lock accepts a new code only when its length
matches the lock's existing code slots, so check room codes and lock slots before changing it.
Tenant save alone does not queue a lock command for this setting; subsequent code generation does.
Treat this as a physical access policy change requiring operator approval. Confirm the saved value
with `GET /api/tenants`, then verify generated codes through
`GET /api/bookings/rooms/{roomId}/codes/health`. Read the tenant before retrying an ambiguous save.
After a successful tenant save, the backend compares the effective wire values for `soundLevel`,
`systemCode`, `nfcEnabled`, and `accessMode` with the previously stored tenant. It queues an
action-27 message for every tenant device only when any effective value changed. Changes to
unrelated tenant fields do not queue device messages. Normalization is applied before comparison:
missing or unknown sound values are equivalent to `low`, missing or invalid system codes are
equivalent to six zero bytes, missing NFC values are equivalent to `false`, and missing or
unrecognized access modes are equivalent to `standard`.
`defaultFromName` is the formatted RFC mailbox used for outbound email when the tenant does not
have a complete tenant-specific SMTP configuration. It defaults to
`Your hotel <no.reply@solvotix.org>` and may use another display name with the authenticated default
address, for example `Trysil Hotell <no.reply@solvotix.org>`. If `smtpHost`, `smtpUsername`, and
`smtpPassword` are all configured, the tenant SMTP transport and `smtpFrom` take precedence.
Changing the display name does not authorize a different sender domain; the address must remain
permitted by the active SMTP provider. The setting affects future email only, queues no device
operation, and is verified by reading the tenant before sending one test message. Do not
automatically retry an ambiguous tenant update without first reading the stored value.
Each queued message contains the newly saved sound, system-menu, and NFC settings but sets the update-time
flag to `false`, so the device applies configuration without replacing its current time anchor. A
new configuration save replaces any already-pending action-27 message for the same device, ensuring
the newest settings win. Delivery is asynchronous: the successful tenant response means the
settings were saved and, when applicable, messages were queued; it does not prove that every
physical device applied them.
Use `GET /api/tenants` to verify the saved tenant settings. Updating tenant settings is reversible
but security-sensitive because the system code controls a physical device menu; require approval
and do not retry after an ambiguous response until the current tenant value has been read back.
Confirm the target tenant before changing the configuration and verify subsequent queue delivery
and physical sound or system-menu behavior.
```yaml
risk: configuration-changing
approval_required: true
idempotent: false
automatic_retry: forbidden-after-ambiguous-response
verification:
- GET /api/tenants and confirm the tenant-wide soundLevel
- GET /api/tenants and confirm the tenant-wide nfcEnabled value
- GET /api/tenants and confirm defaultFromName before sending a test email
- GET /api/smartlocks/configuration/access-mode and confirm the tenant-wide accessMode
- inspect the device queue
- confirm delivery or acknowledgement
- test keypad sound on the physical lock
- confirm the node 10 NFC reader state locally when nfcEnabled changes
- confirm system-menu entry and exit locally when the system code changes
```
### 12.4 Read and set the tenant-wide lock access mode
```http
GET /api/smartlocks/configuration/access-mode
PUT /api/smartlocks/configuration/access-mode
Authorization: Bearer <token>
Tenant: <TENANT_ID>
Content-Type: application/json
```
```json
{
"accessMode": "standard"
}
```
The access mode applies to every keypad lock, lock controller, and wireless code panel in the
tenant (device types 7, 8, 9, and 10). It is the same tenant-wide setting as the `accessMode` field
of the tenant resource; these endpoints exist so a client can read and change only that value
without submitting a full tenant object.
| Value | Device behavior |
|---|---|
| `standard` | A valid code opens the lock and the device closes again on its own. |
| `openUntilClosed` | A valid code keeps the lock open until `*` is pressed on the keypad, a close command is sent, or the physical system menu locks it. |
| `forcedClosed` | Local codes and cards are refused and reported as invalid. The lock can only be opened over the API or from the physical system menu. |
A device that is already held open is never closed by a credential, in any mode: after an
`open` command, a system-menu unlock, or an `openUntilClosed` latch, a valid code or card is
reported as valid and confirmed on the device, but does not schedule a close behind it. The timed
openings are unaffected, so `pulse-open` and the `standard` grant still close on their own.
`GET` returns the stored mode together with `allowedModes`, the list of values this backend
accepts. A tenant that has never been configured reads as `standard`, which is also how a device
with no stored mode behaves.
`PUT` accepts only the three values above; anything else returns 400. A successful save persists
the tenant value and queues an action-27 configuration message for every device in the tenant, with
the update-time flag set to `false`. Delivery is asynchronous: the 200 response means saved and
queued, not applied. A device that is out of range keeps its previous mode until it is heard from
again, and a mode changed locally on a keypad (system menu option `8`) stays in effect on that
device until the tenant configuration is delivered again.
`forcedClosed` prevents every local code and card from opening the lock and closes a lock that
`openUntilClosed` was holding open. It does not disable the API open commands or the physical
system menu. Confirm the operational intent before switching a tenant into it, and confirm that at
least one supported way in remains for the people on site.
```yaml
risk: physical-or-operationally-dangerous
approval_required: true
idempotent: true
automatic_retry: forbidden-after-ambiguous-response
verification:
- GET /api/smartlocks/configuration/access-mode confirms the stored tenant value only
- GET /api/sensors/{sensorId} reportedAccessMode confirms what a device actually runs
- inspect the device queue
- confirm delivery or acknowledgement
- test a code on the physical lock before relying on the new mode
```
## 13. RELAY RECIPES
```text
POST /api/relay/{relayId}/open
POST /api/relay/{relayId}/open-until-closed
POST /api/relay/{relayId}/close
POST /api/relay/{relayId}/pulse/ms/{milliseconds}
POST /api/relay/{relayId}/pulse/seconds/{seconds}
POST /api/relay/{relayId}/pulse/minutes/{minutes}
POST /api/relay/{relayId}/pulse-open
```
```yaml
risk: physical-or-operationally-dangerous
approval_required: true
idempotent: false
mandatory_preconditions:
- relay real-world purpose known
- safe duration known
- target device and tenant confirmed
warning: relay may operate a door, heater, motor, appliance, or alarm interface
automatic_retry: forbidden
```
Generate a package for direct delivery to a relay (for example, by a mobile app over BLE):
```http
POST /api/relay/{relayId}/package
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
```
Persistent open and close:
```json
{ "operation": "open" }
```
```json
{ "operation": "close" }
```
Timed pulse (unit must be `milliseconds`, `seconds`, or `minutes`):
```json
{
"operation": "pulse",
"value": 5,
"unit": "seconds"
}
```
Consumption-limited open accepts an energy allowance in kilowatt-hours:
```json
{
"operation": "consumption",
"kwh": 1.5
}
```
The backend converts `kwh` to CF pulses using `relay.metering.cf-pulses-per-kwh` (deployment
default: 2,175,856 pulses/kWh), rounds to the nearest whole pulse using half-up rounding, and
rejects results outside the unsigned 32-bit range. This default is derived from the relay's
BL0937B reference circuit: a 1 mOhm shunt, six 200 kOhm high-side divider resistors, a 510 Ohm
low-side resistor, and the nominal 1.1 V reference. For example, `1.5` kWh produces 3,263,784
pulses. The response exposes this as `consumptionPulseCount`; the encoded action-40 data is that
count as four little-endian bytes. `kwh: 0` produces zero pulses, which cancels an active countdown
and closes the relay. A deployment-specific calibrated value may override the nominal default.
A successful response returns the selected protocol `action` and `subAction`, a generated
`messageId`, and the complete node-core package as both `packageBase64` and `packageHex`.
Decode exactly one representation and deliver those bytes unchanged. The endpoint only creates
the package: it does not queue, deliver, or execute it, and HTTP 200 does not confirm physical
completion.
```yaml
risk: physical-or-operationally-dangerous
approval_required: true
mandatory_preconditions:
- relay belongs to the authenticated tenant
- relay real-world purpose is known
- target device, operation, and bounds are explicitly confirmed
- a direct transport to the intended relay is available
idempotent: false
automatic_retry_generation: allowed only before any delivery attempt
automatic_retry_delivery: forbidden
verification:
- verify transport-level delivery
- verify device acknowledgement or current relay state
- do not expect a gateway queue entry or backend event from package generation
errors:
- 400 for a non-relay sensor, unknown operation, missing pulse value/unit, missing or non-finite kwh, invalid calibration, or an out-of-range calculated pulse count
- 404 when the sensor does not exist in the tenant
```
Set default pulse duration:
```http
PUT /api/relay/{relayId}/default-milliseconds
Content-Type: application/json
```
```json
{ "openMilliseconds": 150 }
```
### 13.1 Scheduled lock automation
Lock automation runs open, close, and pulse-open on a recurring schedule across locks, lock
controllers, and relays. A task names one trigger, one schedule, and one or more devices.
```text
GET /api/automation/locks/triggers/config
GET /api/automation/locks/tasks
POST /api/automation/locks/tasks
PUT /api/automation/locks/tasks/{taskId}
DELETE /api/automation/locks/tasks/{taskId}
POST /api/automation/locks/tasks/{taskId}/run
```
All six operations require a superuser token; a non-superuser receives 403. The tenant must have
the **Lock Automation** module enabled under **Settings → Modules** (`automation_lock` in
`/api/modules/settings`) for scheduled or manual execution. Task management stays available while
the module is off, but no task executes. Disabling the module suspends all physical operations
without deleting configuration.
Supported triggers and their effect per device family:
| Trigger | Locks and lock controllers | Relays |
|---|---|---|
| `open` | unlocks and stays open | closes the circuit and stays closed until a `close` runs |
| `close` | locks | opens the circuit |
| `pulse_open` | short open pulse, closes by itself | pulse for the relay's configured `relay_open_ms` |
Create a task that unlocks the main entrance every weekday at 08:00:
```http
POST /api/automation/locks/tasks HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
```
```json
{
"name": "Unlock main entrance",
"comment": "Opens the front door for the morning shift",
"trigger": "open",
"deviceIds": ["A1B2C3D4E5F60708"],
"schedule": {
"frequency": "WEEKLY",
"interval": 1,
"byWeekdays": ["MON", "TUE", "WED", "THU", "FRI"],
"timeOfDay": "08:00",
"startDate": "2026-09-01",
"timeZone": "Europe/Oslo"
}
}
```
A task carries exactly one trigger. Locking again at 16:00 is a second task with
`"trigger": "close"` and `"timeOfDay": "16:00"`.
Schedule fields:
- `frequency` — `ONCE`, `DAILY`, `WEEKLY`, or `MONTHLY`.
- `interval` — repeat every N days, weeks, or months, counted from `startDate`. Defaults to 1.
- `byWeekdays` — required for `WEEKLY`. Weekday names such as `MON` or `MONDAY`.
- `byMonthDays` — required for `MONTHLY`. Days 1-31; a day that does not exist in a month is
skipped for that month.
- `timeOfDay` — required, 24-hour `HH:mm`.
- `startDate` — required, `YYYY-MM-DD`. Recurrence intervals are counted from this date.
- `endDate` — optional, `YYYY-MM-DD`. Omit for an open-ended schedule.
- `timeZone` — optional IANA identifier, defaulting to the tenant time zone.
Schedules are evaluated in local time, so a task stays at its local clock time across daylight
saving transitions.
Execution semantics an agent must account for:
- The scheduler polls once a minute, so a trigger fires within roughly a minute of its local time.
Treat the scheduled time as approximate.
- A scheduled occurrence executes at most once. `lastFiredOccurrence` on the task holds the local
date-time of the most recent scheduled occurrence. `lastFiredAt` is the server time of the most
recent scheduled or manual dispatch attempt; it does not prove physical completion.
- Occurrences missed while the backend was not running are skipped, not replayed, once they fall
outside the catch-up window (`lock.automation.catch-up-window-minutes`, deployment default 10
minutes). A task that did not fire is expected behavior after an outage, not an error to retry.
- Each device is dispatched independently. One unreachable device does not stop the others.
- Deleting a task stops future occurrences but does not recall commands already queued to a gateway.
Every executed and failed dispatch is written to the event log per device, so delivery is verified
through `/api/events` in the same way as a manual lock or relay command. A queued command does not
prove physical completion.
To run an existing task immediately, first read `GET /api/automation/locks/tasks` and confirm the
task ID, trigger, and every target device. Obtain explicit approval for that physical action. In
the portal, open **Automation → Lock Automation**, then choose **Run now** from the task's action
menu. The task must be enabled and the tenant's Lock Automation module must be on. The schedule is
not checked: the configured trigger is queued immediately for every device. The backend updates
`lastFiredAt` to the manual run's `requestedAt` before dispatching, even if every device fails to
queue. It leaves `lastFiredOccurrence` unchanged, so a scheduled occurrence may still run at about
the same time. `GET /api/automation/locks/tasks` returns the persisted last-run time after a run.
```http
POST /api/automation/locks/tasks/8f14e45f-ceea-467a-9575-4a1f3b7c2d90/run HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```
There is no request body. A `200` response reports one outcome per device, for example:
```json
{
"taskId": "8f14e45f-ceea-467a-9575-4a1f3b7c2d90",
"runId": "da262322-79ef-4f77-8b15-8cff008682a2",
"trigger": "open",
"requestedAt": "2026-09-14T08:00:00Z",
"devices": [
{ "deviceId": "A1B2C3D4E5F60708", "queued": true, "status": "queued" }
]
}
```
Each device runs independently. `status` can be `queued`, `not_found`, `invalid_device`,
`invalid_request`, or `failed`. A queued result only means accepted for queuing; it does not prove
gateway delivery or physical action. Read `/api/events` for each device: the manual run appears in
the lock automation executed or failed event with `occurrence` equal to `manual:<runId>` and the
requesting user as actor. Then verify the physical lock or relay state. `404` means the task is
absent from the authenticated tenant; `409` means the task or module is disabled or the task is
otherwise not runnable; `401` and `403` indicate missing authentication or superuser access.
Manual execution is non-idempotent. Never retry automatically after a timeout or ambiguous
response; inspect the events and devices first, because a second call sends a second command.
`PUT` replaces the name, comment, trigger, devices, schedule, and enabled state, so send the whole
task rather than a partial one. `userId` and `createdDate` are preserved, and the already-fired
marker is kept so an occurrence that already ran does not run again after an edit. Set `enabled` to
false to suspend a single task while keeping its configuration.
```yaml
risk: physical-or-operationally-dangerous
approval_required: true
authorization: superuser only
mandatory_preconditions:
- every device's real-world purpose is known
- unattended operation at the scheduled times is safe and intended
- target devices, trigger, schedule, and time zone are explicitly confirmed
warning: a task repeatedly opens or closes physical access without an operator present
idempotent:
create: false
update: true
delete: true
run_now: false
automatic_retry:
create: forbidden-after-ambiguous-response
update: allowed after re-reading the task
delete: allowed
run_now: forbidden-after-ambiguous-response
verification:
- GET /api/automation/locks/tasks and confirm the stored schedule, trigger, and devices
- confirm the Lock Automation module is enabled in /api/modules/settings
- after a scheduled time, read lastFiredOccurrence and lastFiredAt
- inspect /api/events per device for the executed or failed operation
- confirm physical device state
- for Run now, inspect each response device outcome and correlate /api/events occurrence manual:<runId>
- after Run now, GET /api/automation/locks/tasks and confirm lastFiredAt matches requestedAt or a later run time; this verifies a recorded attempt, not physical completion
errors:
- 400 for a missing name, unknown trigger, empty device list, a device that is not a lock, lock controller, or relay, or an invalid schedule
- 403 when the token is not a superuser
- 404 when the task does not exist in the tenant
```
## 14. THERMOSTAT AND TEMPERATURE RECIPES
```http
POST /api/thermostat/{thermostatId}/target-temperature
Content-Type: application/json
```
```json
{
"temperature": 21.0,
"gw_id": "<OPTIONAL_GATEWAY_ID>"
}
```
```text
POST /api/thermostat/{thermostatId}/on
POST /api/thermostat/{thermostatId}/off
POST /api/thermostat/{thermostatId}/restart
POST /api/thermostat/{thermostatId}/wifi
POST /api/thermostat/{thermostatId}/delete-wifi
GET /api/temperature-control/stats
GET /api/temperature-control/history/{sensorId}
```
Wi-Fi credentials are secrets. Never print or persist them outside the intended secret store.
### 14.1 Decode temperature and humidity
Nodes report measurements inside their Bluetooth advertisement. `GET /api/sensor` returns every
sensor of the tenant with that advertisement attached as `adv_data`, whose `sensor_data` field is the
three measurement bytes as an uppercase hex string, six characters, byte 0 first.
```json
{
"id": "<SENSOR_ID>",
"type": "temperature",
"unit": "read",
"temperatureC": 20.92,
"lastHeardFrom": "2026-01-14T09:12:04.000+00:00",
"adv_data": {
"node_type": 4,
"battery_level": 87,
"sensor_data": "2C082D"
}
}
```
On temperature nodes — `type` `temperature` (node type 4) and `wall_thermostat` (node type 11) —
the three bytes decode as follows.
| Byte | Meaning | Decoding |
|---|---|---|
| 0-1 | Temperature | signed 16-bit little-endian hundredths of a degree Celsius: `celsius = int16le(byte0, byte1) / 100` |
| 2 | Relative humidity | unsigned byte, whole percent, `0`-`100` |
Bytes 0-1 equal to `FFFF` mean the node has no valid temperature reading. In the example above,
bytes `2C 08` are `0x082C` = 2092, so 20.92 °C, and byte `2D` is 45 % relative humidity.
The backend decodes the temperature already and returns it on every sensor as `temperatureC` in
degrees Celsius. Prefer that field; decode bytes 0-1 yourself only when verifying a raw
advertisement. When the sensor is not a temperature node, has never advertised, or carries the
`FFFF` no-reading marker, `temperatureC` is the non-numeric value NaN and is serialized as the
string `"NaN"`. Treat that as "no reading"; never report it as a measurement, and never coerce it to
`0`.
Humidity is not decoded, aggregated, or stored anywhere in the backend. Byte 2 of `sensor_data` on
`GET /api/sensor` is the only source, and it is a live value only: there is no humidity history and
no humidity alarm.
On relay nodes (`relay`, `high_voltage_relay`) byte 0 is the open state, `00` closed and `01` open,
and bytes 1-2 are unused. Every other node type reports `000000`.
Lock nodes (`lock_8015`, `lock_s42`, `lock_controller`, `lock_wireless_code_panel`) report their
open state in `adv_data.node_settings` instead, not in `sensor_data`. `node_settings` is three bytes
as an uppercase hex string; byte 0 carries bit flags:
| Mask | Meaning |
|---|---|
| `0x0F` | advertising power profile, `0`-`9` |
| `0x10` | lock is held open |
| `0x80` | node uses the encrypted communication layout |
Bit `0x10` is set while the lock is open with nothing scheduled to close it: a backend open, a
forced open, a system-menu unlock, or an open-until-closed latch. It is clear for a timed open,
which closes itself, so a pulse open does not raise it. On the wireless code panel the bit
describes the paired relays the panel drives. The node re-advertises within moments of the state
changing rather than waiting for its next periodic advertisement. Firmware older than v115 always
reports the bit as `0`, and mask the byte rather than comparing it whole, since the power profile
and encryption bits share it.
Readings are only as fresh as the last advertisement the gateways heard. Judge age with
`lastHeardFrom` before acting on a value, and do not treat an unchanged reading as proof the node is
online. For historical or aggregate temperature use `GET /api/temperature-control/history/{sensorId}`,
which returns 5-minute readings already decoded to `temperatureC`, and `GET /api/temperature-control/stats`,
which returns the newest 5-minute snapshot with `averageTempC`, `lowestTempC`, `highestTempC` and the
`sensorCount` that contributed. Sensors without a valid reading are excluded from both, and no
snapshot is written for an interval in which no sensor had one.
```yaml
risk: read-only
approval_required: false
idempotent: true
sources:
- GET /api/sensor for the current temperature and the only humidity value
- GET /api/temperature-control/history/{sensorId} for stored per-sensor temperature readings
- GET /api/temperature-control/stats for the newest tenant-wide temperature snapshot
decoding:
temperature: signed-16-bit-little-endian-hundredths-celsius-from-sensor_data-bytes-0-1
temperature_no_reading_marker: FFFF
humidity: unsigned-percent-from-sensor_data-byte-2
verification:
- compare temperatureC with bytes 0-1 of adv_data.sensor_data
- check lastHeardFrom before treating a reading as current
never:
- report a NaN temperature as a measurement
- expect humidity in the temperature history or stats endpoints
```
## 15. DEVICE PAIRING
```http
PUT /api/sensor/{sensorId}/pairings
Content-Type: application/json
```
```json
{
"sensorIds": [
"<PAIRED_DEVICE_ID_1>",
"<PAIRED_DEVICE_ID_2>"
]
}
```
```yaml
read_current: GET /api/sensor/{sensorId}/pairings
fetch_from_hardware: POST /api/sensor/{sensorId}/pairings/fetch
risk: configuration-changing
approval_required: true
verification: compare stored and fetched pairings
```
## 16. FIRMWARE LIFECYCLE
```text
GET /api/node-updates/firmwares
POST /api/node-updates/sensors/{sensorId}
DELETE /api/node-updates/sensors/{sensorId}
DELETE /api/node-updates/sensors/{sensorId}/queue
```
Start payload:
```json
{ "version": "<AVAILABLE_VERSION>" }
```
```yaml
risk: operationally-dangerous
approval_required: true
automatic_retry: forbidden
preconditions:
- version exists in firmware catalog as a complete four-file bundle
- device compatibility confirmed
- stable power confirmed
- connectivity confirmed
- maintenance window confirmed
- rollback expectations understood
- for a node known to hold a trust anchor, the adjacent offline-signed manifest and signature verify against the adjacent per-release signer certificate
signed_package_activation:
applies_when: the backend security record indicates that the node holds a trust anchor
firmware_catalog_bundle:
- /firmware/nrf_node-<version>.bin
- /firmware/nrf_node-<version>.manifest.bin
- /firmware/nrf_node-<version>.manifest.sig
- /firmware/nrf_node-<version>.signer.der
catalog_visibility: a firmware image is listed only when all three adjacent metadata files exist
release_signing:
location: offline trusted operator computer
backend_private_key_required: false
backend_behavior:
- load the pre-signed manifest, signature, and public signer certificate without modifying them
- verify image digest, exact size, filename version, target node type, manifest format, and algorithms
- validate the per-release signer certificate against the backend intermediate and verify the manifest signature
- send the exact verified manifest, signature, and signer certificate to the node
manifest_binds:
- SHA-256 of the exact firmware image
- exact image size
- firmware version
- target node type, or the protocol's explicit all-node value when type is unavailable
signature: RSA-2048 PKCS#1 v1.5 with SHA-256
certificate_chain:
- the package includes a CA-false code-signing leaf with digitalSignature and codeSigning usage
- the package includes the issuing intermediate
- the node validates the chain against its existing stored root certificate
activation_gate:
- the node hashes the completed staged flash image
- the staged digest must match START, FINALIZE, and the signed manifest
- manifest format, image size, firmware version, target node type, and flags must be valid
- certificate roles, chain, and manifest signature must verify
- only then may the node mark the MCUboot slot for test boot
transport: application-layer encryption is preferred when a session is active, but release-signature verification is independent of transport encryption
backend_startup: no global flash-signing certificate or offline private key is required
secure_ota_availability: the public signer certificate is supplied by each complete release bundle
failure: a missing bundle file, invalid metadata, public certificate, chain, signature, or digest fails closed without activating flash or falling back to unsigned OTA
rootless_recovery: nodes without a trust anchor retain the legacy unsigned OTA path
smart_lock_code_storage_transition:
boundary_firmware_version: 111
upgrade_from_legacy_to_111_or_newer:
- code slots 1 through 987 migrate automatically
- codes in slots 988 through 1980 are not retained and must be pushed again
downgrade_from_111_or_newer_to_legacy:
- legacy firmware treats the version-6 code store as empty
- every access code must be pushed again after the downgrade
- saving codes with legacy firmware can overwrite the firmware-111 certificate and session storage
- after the approved secure-session retirement, device communication uses the legacy plaintext protocol
required_action: warn the operator and plan code resynchronization before starting the update
queue_isolation:
- while a node OTA update is active, the backend sends only OTA chunk and OTA-completion packages to that node
- regular packages already queued for the node remain queued and resume after the OTA update finishes or is aborted
- new non-OTA packages for the node are rejected before they enter the outbound queue, including time-setting and security-handshake packages
- only firmware actions 21, 22, and 23 may be newly queued for the reserved node
- OTA packages take precedence over earlier regular packages for the same node
- packages for other nodes are unaffected
delivery_confirmation:
- unencrypted and encrypted OTA use the node's positive six-byte BLE acknowledgement relayed by the gateway as status 2 for the exact message ID
- that status-2 acknowledgement removes the exact queued chunk, persists its registered progress, and refills the OTA window
- encryption applies to the OTA command payload; the node-to-gateway BLE acknowledgement intentionally remains unencrypted
- for a trusted node, transport delivery does not authorize flash activation; the release signature and certificate chain are verified on-device at FINALIZE
- an authenticated Action 46 result is an audit signal and idempotent delivery fallback, not a prerequisite for OTA progress
- exception for a secure-to-legacy downgrade: status 2 advances OTA transport progress but cannot retire the active secure session
- the old secure session is retired only when Action 46 authenticates the exact secure finalize message, or after positive finalize transport delivery when the node advertises the exact legacy firmware version recorded by the approved downgrade request
- a missing encryption advertisement flag, a different legacy version, or an unsolicited legacy advertisement never authorizes plaintext fallback
- up to eight OTA frames may be queued; a transport-confirmed frame frees its window position, but the backend enforces at least 250 ms before dispatching the next OTA frame to that tenant-scoped node
- if the transport acknowledgement is absent, the same logical chunk remains queued for bounded internal retry; callers must not start a second update
- node-originated encrypted responses retire from the node queue on the gateway transport acknowledgement and do not require a backend Action 46 command during OTA isolation
recovery:
- an authenticated BAD_STATE for an encrypted chunk or finalize means the node lost its volatile OTA start state
- the backend discards the current OTA window and restarts the same transfer from its authenticated START package
secure_to_legacy_downgrade:
applies_when: a node with an active secure session is explicitly updated to firmware below version 111
authorization: the requested target version is persisted before secure OTA packages are queued
transport: the image, digest, chunks and finalize package remain authenticated by the existing secure session, and flash activation independently requires a release signature chaining to the node's stored root
transition:
- the gateway status-2 receipt alone does not retire the session
- an authenticated Action 46 acknowledgement for the exact finalize message retires the old key
- after positive secure-finalize transport delivery, a post-reboot advertisement retires the old key only when its firmware version exactly matches the persisted downgrade target
- after retirement, queued logical commands resume through the legacy plaintext node protocol
abort: clears the persisted downgrade authorization and preserves the active secure session
warning: legacy node communication is not protected by the version-111 application-layer encryption protocol
certificate_rotation:
availability: disabled by default; these maintenance endpoints are not registered and return 404 unless an operator explicitly starts the backend with solvotix.security.rotation-api.enabled=true
default_enabled: false
re_enable: set solvotix.security.rotation-api.enabled=true and restart the backend; enable only for an approved maintenance window, then disable and restart afterward
endpoints:
operational: POST /api/node-security/sensors/{sensorId}/operational-certificate-rotation
session_key: POST /api/node-security/sensors/{sensorId}/session-key-rotation
root: POST /api/node-security/sensors/{sensorId}/root-certificate-rotation
authentication: bearer token plus tenant context supplied by the authentication filter
operational_request_body: none
root_request_body:
certificatePem: exactly one PEM-encoded CA transition certificate containing the new root public key, signed by the currently trusted root; never a private key
response: 202 with sensorId, gatewayId, queuedMessages, generation, fingerprint, and status=queued
risk: security-sensitive
approval_required: true
preconditions:
- sensor belongs to the authenticated tenant
- node has an active authenticated session
- the node has a known gateway route; delivery may wait in the persistent queue while that gateway changes live readiness state
- no certificate rotation is already pending
- backend has restarted after loading the intended generation certificate and matching private key
errors:
- 400 when the sensor ID is invalid or not found in the tenant
- 409 when the node is not secure, has no known gateway route, is already pending rotation, has active OTA, or a queue fragment is rejected
- 500 when configured certificate material cannot be encoded
transport: action 47 rotation fragments require an active authenticated node session
fragment_payload: maximum 177 DER bytes after the eight-byte header; remaining 16 data bytes carry the AEAD tag
generation:
source: non-zero unsigned 32-bit certificate serial signed by the issuing CA
rule: a replacement generation must be greater than the persisted generation
issuance: use deliberate serials 1, 2, 3 and not default wide random serials
queue_confirmation:
- every authenticated rotation fragment is retired by its exact Action 46 acknowledgement
- fragment acknowledgement proves processing only, not certificate installation
installation_confirmation:
action: 47
sub_action: 4
payload: version, role, status, generation LE32, and installed SHA-256 fingerprint
acceptance: backend matches role, generation, and fingerprint against the persisted pending transaction
mismatch: retain pending state, record a security error, and do not report completion
operational_fallback: an exact pending operational generation and fingerprint may also complete after the replacement session passes mutual challenge confirmation, because that exchange proves the node installed the matching operational public key
rollback_protection:
- older generations are rejected
- equal generations are idempotent only for the identical persisted fingerprint
- the first valid OTA signer is pinned; a different signer requires a higher signed generation
operational_rotation:
- send the issuing intermediate and operational certificate as one tracked transaction
- after persistence the node starts fresh enrollment using the new operational public key
session_key_rotation:
- reassert the identical pinned intermediate and operational certificate through the operational rotation transaction
- returns 409 if the node's recorded operational fingerprint differs from the backend's configured certificate; rotate the operational certificate first
- node generates fresh AES-256 session material and encrypts it to the operational public key
- old session remains authoritative until the pending session passes mutual challenge confirmation and is promoted atomically
- if the node promotes the pending key but its final confirmation is lost, repeating this endpoint queues only a fresh pending-key confirmation with a new sequence; it does not restart certificate rotation
- updated nodes answer a repeated valid backend confirmation for their active key idempotently, including after reboot, allowing the backend to promote the same pending key
- verify completion by observing DeviceSecurity.sessionGeneration increase; a cleared certificate rotationState alone proves certificate processing, not completion of the subsequent key exchange
root_rotation:
- create the transition certificate offline while the current root signing key remains available
- the backend verifies its signature with the current root, CA:TRUE role, size, and generation before queueing
- after root confirmation, install a new intermediate and operational chain under that root and invoke operational rotation
verification:
- 202 means queued, not installed
- inspect the node queue and DeviceSecurity rotationState after submission
- completion requires rotationState to clear after the exact authenticated installation result
retry: an explicitly repeated certificate request is idempotent only when role, generation, and fingerprint exactly match the pending transaction; repeating session-key rotation while its replacement session is pending resumes mutual confirmation; conflicting rotation requests return 409 and external agents must not retry automatically
cancellation:
endpoint: DELETE /api/node-updates/sensors/{sensorId}
tenant_context: required from the authenticated request
approval_required: true
response: 200 with the number of OTA queue packages removed
idempotency: safe to retry; returns 200 with 0 when already clean
effect:
- cancel the backend OTA session
- delete in-memory and persisted OTA queue packages for the sensor
- clear in-flight delivery and chunk-progress tracking
- clear any pending secure-to-legacy downgrade authorization without deleting the active secure session
- reset the sensor update flag and progress
authority: this is the only operation that cancels an OTA update; successful finalize completes it normally
concurrent_start: POST returns 409 without modifying the existing update
generic_queue_delete: DELETE /api/node-updates/sensors/{sensorId}/queue returns 409 without changing the queue while OTA state or packages exist
```
## 17. QUEUE AND EVENT VERIFICATION
```yaml
queue_endpoints:
tenant: GET /api/gateways/getqueue
device: GET /api/gateways/queue/device/{deviceId}
device_transfer_packages: GET /api/gateways/queue/device/{deviceId}/packages
gateway: GET /api/gateways/{gatewayId}/gwqueue
event_endpoints:
tenant: GET /api/events
device: GET /api/events/sensor/{sensorId}
room: GET /api/events/byRoom/{roomId}
paged: GET /api/events/page
```
`GET /api/events/page?page=0&size=40` reads the newest tenant events first. The response is a
Spring page with `content`, `totalElements`, `number`, `size`, and `last`; page numbers start at 0.
Request subsequent pages until `last` is true. `size` defaults to 40 and must be 1–100. Optional
`from` and `to` must be supplied together as inclusive ISO-8601 instants, for example
`GET /api/events/page?page=0&size=40&from=2026-04-01T00%3A00%3A00Z&to=2026-04-15T23%3A59%3A59Z`.
Add either `sensorUuid=<uuid>` or `roomId=<id>` to filter; they cannot be combined, and room queries
require the time range. Results are ordered by descending creation time and ID. Invalid pagination,
filters or dates return 400; an unknown room returns 404. The same bearer authentication and
`Tenant` header as other event reads are required. Receptionist responses censor access codes.
This read has no physical effect, needs no approval, and is safe to retry. Offset pages can shift
when events arrive between requests, so clients should deduplicate by event ID and refresh the first
page for new activity. A 200 means only that events were retrieved; an event's presence alone does
not prove that a physical device completed an operation. Verify the specific operation and device
state according to its command contract.
Booking update events use `action: 1034`. Inspect `data.state` instead of inferring a
transition from the booking snapshot: `checked_out` means the booking changed from not checked
out to checked out, while `updated` means another field changed and may still contain
`checkedOut: "true"` as the current state. Treat only `data.state: "checked_out"` as a checkout
event. Event reads are safe and require the same bearer authentication and tenant context as the
other tenant-scoped API operations.
### 17.0.1 Event action registry
Events have two action namespaces. Values `0` through `40` come from the device protocol. Values
`1024` through `1037` are server-generated application events. Do not compare a `sub_action`
without first checking its parent `action`.
| Action | Device-protocol meaning | Action | Device-protocol meaning |
|---:|---|---:|---|
| 0 | gateway online | 1 | button pressed |
| 2 | passive alarm triggered | 3 | active alarm triggered |
| 4 | temperature high | 5 | unusual motion |
| 6 | battery low | 7 | power surge |
| 8 | water leakage | 9 | unauthorized access |
| 10 | door left open | 11 | ventilation anomaly |
| 12 | freezing temperature | 13 | high air pressure |
| 14 | temperature too low | 15 | device online |
| 16 | device offline | 17 | gateway status |
| 18 | test | 19 | alarm cleared |
| 20 | update advertising profile | 21 | firmware update |
| 22 | firmware update chunk | 23 | firmware update chunk complete |
| 24 | restart node | 25 | new node |
| 26 | ask for time | 27 | set time |
| 28 | relay pulse, milliseconds | 29 | relay pulse, seconds |
| 30 | relay open | 31 | relay close |
| 32 | relay pulse, minutes | 33 | lock operation |
| 34 | wall thermostat operation | 35 | restart |
| 36 | restart mode on | 37 | restart mode off |
| 38 | gateway ping with status | 39 | gateway metering data |
| 40 | relay pulse count | | |
| Action | Application event | `data.state` | Important data |
|---:|---|---|---|
| 1024 | room marked cleaned | `cleaned` | `entityId`, optional `entityName`, `actorUserId` |
| 1025 | room marked dirty | `dirty` | `entityId`, optional `entityName`, `actorUserId` |
| 1026 | gateway marked online | `online` | `entityId`, `actorUserId` |
| 1027 | gateway marked offline | `offline` | `entityId`, `actorUserId` |
| 1028 | smart-lock code added | `added` | device identity, sensitive `code`, `actorUserId` |
| 1029 | smart-lock code removed | `removed` | device identity, sensitive `code`, `actorUserId` |
| 1030 | booking message sent | not set | booking/room, channels, recipient and message fields |
| 1031 | sensor restart mode enabled | `on` | device identity, `actorUserId` |
| 1032 | sensor restart mode disabled | `off` | device identity, `actorUserId` |
| 1033 | booking created | `created` | booking snapshot |
| 1034 | booking updated | `updated` or `checked_out` | booking snapshot; only `checked_out` is a checkout transition |
| 1035 | booking deleted | `deleted` | final booking snapshot |
| 1036 | lock automation command accepted | `queued` | `taskId`, `taskName`, `trigger`, `occurrence` |
| 1037 | lock automation failed | producer status or `failed: ...` | task and occurrence details |
Action-specific sub-actions:
| Parent action | Sub-action values |
|---:|---|
| 33, lock operation | 1 pulse open; 2 open; 3 close; 4 add codes; 5 remove codes; 6 set configuration; 7 delete all codes; 8 valid legacy PIN; 9 invalid legacy PIN; 10 pair devices; 11 fetch paired devices; 12 valid extended credential; 13 invalid extended credential; 14 add extended credential; 15 closed by `*` |
| 34, wall thermostat | 1 set Wi-Fi; 2 set target temperature; 3 turn on; 4 turn off; 5 restart; 6 delete Wi-Fi; 7 receive debug data |
| 39, gateway metering | 12 five-minute; 13 hourly; 14 total; 15 consumption changed |
Sub-action `15` is reported by the lock itself when someone presses `*` to end an
open-until-closed hold, on firmware v115 and later. It carries no credential and no payload, so it
has none of the code or credential `data` keys: the event is the fact that the lock was closed at
the door, with `sensor_uuid` and the event timestamp. A `*` press on an already closed lock reports
nothing. Locks running earlier firmware never send it, so its absence is not evidence that a lock
was not closed.
Extended credential result events (action 33 with sub-action 12 or 13) expose `slot`, `valid`,
`state`, `factorCount`, `credentialType`, and `credentialId`. MIFARE factors additionally expose
`mifareUid`; wallet factors expose `walletPlatform`; PIN factors use `code`. A rejected credential
uses slot `0`. Credential identifiers and PINs are security-sensitive.
Common structured `data` keys are `entityType`, `entityId`, `entityName`, `state`,
`actorUserId`, `roomId`, `bookingId`, `code`, `slot`, `valid`, `factorCount`, `credentialType`,
`credentialId`, `mifareUid`, `walletPlatform`, `triggerId`, `taskId`, `taskName`, `trigger`,
`occurrence`, `channels`, `message`, `emailMessage`, `smsMessage`, `recipient`, `recipientEmail`,
and `recipientPhone`. Keys are event-specific and may be absent. Access codes, recipient details,
and message bodies are sensitive and must not be exposed outside their authorized purpose.
Example checkout transition:
```json
{
"action": 1034,
"sub_action": null,
"roomId": "room-101",
"data": {
"entityType": "booking",
"entityId": "booking-123",
"bookingId": "booking-123",
"roomId": "room-101",
"state": "checked_out",
"checkedOut": "true",
"actorUserId": "system"
}
}
```
```yaml
authentication: "Authorization: Bearer sat_<TOKEN>"
tenant_context: "Tenant: <TENANT_ID> is required and is resolved by the authentication filter"
preconditions:
- use an event endpoint appropriate to the tenant, device, or room
- provide valid inclusive ISO-8601 bounds where required
risk: read-only
approval_required: false
idempotency: safe
retry_policy: retry transient read failures with bounded backoff; do not create conclusions from duplicate records
verification:
- identify the event by action and structured data
- interpret sub_action only under its parent action
- treat queued as accepted, not physically completed
- corroborate physical operations with later device state or device-originated events
errors:
- 400 means an invalid identifier or time range
- 401 or 403 means authentication or authorization failed
- 404 on a room query means the room was not found
```
Verification algorithm:
```text
1. Capture the returned message ID and target device.
2. Set local status to requested or queued.
3. Inspect device and gateway queue state.
4. Inspect relevant events.
5. Re-read current device state when supported.
6. Report exactly one state:
requested | queued | delivered | confirmed | failed | unknown
7. Never translate queued into completed.
```
### 17.1 Manually transfer a device queue
```http
GET /api/gateways/queue/device/{deviceId}/packages
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```
The response is an array in outbound queue order. Each entry contains `deviceId`, `queuedAt`,
`messageId`, `action`, `subAction`, `packageBase64`, and `packageHex`. The package fields encode
the same complete 212-byte node-core message. Decode exactly one representation and deliver the
bytes unchanged through the device's direct transport.
This endpoint is read-only and tenant-scoped. It preserves the queued message ID, timestamp,
payload, and checksum, and does not mark, remove, or acknowledge a message. An empty array means
the tenant currently has no pending messages for that device.
```yaml
risk: depends-on-queued-operation
approval_required_before_delivery: inherit from each queued operation
download_idempotent: true
automatic_retry_download: allowed before delivery
automatic_retry_delivery: forbidden
mandatory_preconditions:
- target device and tenant confirmed
- queued action and sub-action understood
- direct transport reaches the intended device
delivery_order: preserve response order
sensitive_data:
- packages may contain credentials, access codes, or configuration
- never log or persist decoded payloads outside approved secure storage
verification:
- require device or transport acknowledgement for each message ID
- verify resulting device state when supported
- HTTP 200 confirms download only, never delivery or physical completion
queue_removal:
endpoint: DELETE /api/gateways/queue/message/{messageId}
precondition: remove only after positive device acknowledgement for that message ID
automatic_removal_on_download: false
errors:
- 500 when a queued node-core message cannot be serialized
```
### 17.2 Node-core package format
Lock packages, relay packages, and downloaded queue packages use the same fixed 212-byte message:
| Offset | Length | Field | Encoding |
|---:|---:|---|---|
| 0 | 6 | message ID | raw bytes; displayed as 12 uppercase hex characters |
| 6 | 2 | timestamp | unsigned 16-bit Unix-seconds value, little-endian |
| 8 | 1 | action | unsigned protocol action |
| 9 | 1 | sub-action | unsigned protocol sub-action |
| 10 | 201 | data | operation-specific bytes followed by zero padding |
| 211 | 1 | checksum | XOR of bytes 0 through 210 |
The timestamp contains the low 16 bits of Unix time and therefore wraps. Do not interpret it as
a standalone wall-clock timestamp. `packageBase64` and `packageHex` encode the entire message,
including its checksum; clients must decode one representation and must not recalculate, replace,
or otherwise modify fields in a downloaded queued package.
The node-core package is not itself a complete BLE transport specification. Service UUIDs,
characteristics, MTU negotiation, chunk framing, write mode, timeouts, and acknowledgement frames
must come from the supported device transport library or firmware protocol for the target device.
Do not guess these values. If no supported transport implementation is available, stop before
delivery.
Queue downloads can contain any queued device command, including security-sensitive configuration,
access-code payloads, restart operations, pairing operations, or firmware traffic. Inspect `action`
and `subAction`, determine that the target transport supports that operation, and apply the original
operation's approval policy. Downloading does not reserve a queue entry or suspend gateway delivery;
avoid manual transfer while a gateway can concurrently deliver the same command.
### 17.3 Tested mobile BLE discovery and delivery
This section defines the tested direct-delivery implementation for Solvotix node-core devices. It
applies when a mobile application must find a nearby device and deliver a package returned by the
relay package endpoint or manual queue-transfer endpoint.
```yaml
advertisement:
expected_local_name: SVN
solvotix_manufacturer_id: 0x79fd
local_name_reliable_on_android: false
node_core_gatt:
service_uuid: 12345678-1234-5678-1234-56789abcdef0
write_characteristic_uuid: 12345678-1234-5678-1234-5678efbeadde
package_bytes: 212
preferred_write_mode: write-without-response
fallback_write_mode: write-with-response-when-characteristic-requires-it
maximum_chunk_bytes: 215
```
Do not use the backend `active`, online, or offline field to determine physical proximity. That
field describes backend communication state, not whether the phone currently receives the device's
BLE advertisement.
Discovery algorithm:
```text
1. Load the tenant's sensor inventory and normalize every sensor UUID to lowercase hexadecimal
without separators.
2. Initialize BLE and obtain the platform scan and connect permissions.
3. While the device screen is visible, run a low-latency scan with duplicate advertisements enabled.
4. Use one application-wide scan coordinator. Starting a scan must stop and replace the previous
native scan owner because mobile BLE plugins commonly expose one global scan operation.
5. Read local name and manufacturer data from every advertisement.
6. Accept the exact local name SVN, but never require it: Android may omit the name.
7. Identify a Solvotix advertisement by manufacturer ID 0x79fd or by a manufacturer-derived UUID
that matches the authenticated tenant's sensor inventory.
8. Construct the primary 8-byte sensor identifier as the manufacturer ID in little-endian byte
order followed by the first six manufacturer payload bytes. Also tolerate stacks that expose
the complete identifier as payload bytes 0..7 or 2..9.
9. Mark an inventory device nearby only when its normalized UUID matches a current advertisement.
10. Refresh last-seen time for duplicate advertisements and remove nearby state after 10 seconds
without an advertisement.
11. Renew a long-running scan periodically and retry a failed scan after a short delay.
12. Stop scanning before connecting. Resume scanning after disconnect while the screen remains
visible.
```
Platform permissions:
```yaml
android:
runtime:
- Bluetooth scan permission
- Bluetooth connect permission
behavior:
- request Bluetooth enablement when disabled
- do not assume localName or device.name is present
ios:
configuration:
- provide the Bluetooth usage description required by the current iOS SDK
behavior:
- initialize Bluetooth only in response to an application flow that needs it
```
Direct-delivery algorithm:
```text
1. Confirm that the target inventory UUID currently maps to a nearby BLE device ID.
2. Obtain explicit approval for the physical operation when required by its risk class.
3. Display a blocking communication overlay before requesting or decoding the package. Keep it
visible through connection, discovery, all writes, and disconnection.
4. Decode exactly one backend package representation. Require exactly 212 bytes and do not alter
the package.
5. Stop the active scan and disconnect any stale connection for the target BLE device ID.
6. Connect and discover the specified node-core service and write characteristic. Do not select an
unrelated writable characteristic as a substitute.
7. Read the negotiated MTU when supported. Use chunk size max(20, min(215, MTU - 3)); when MTU is
unavailable, assume MTU 23 and send 20-byte chunks.
8. Write chunks sequentially. Prefer write-without-response when advertised; otherwise use
write-with-response. Do not automatically retry after any chunk delivery attempt.
9. Treat completion of all BLE writes as transport completion, not proof that the physical action
occurred. Use a device acknowledgement or observed current state when the firmware exposes one.
10. Disconnect in a finally/finalization path, report success or failure in the overlay, and restart
continuous scanning after a short settling interval.
```
For manual queue delivery, preserve response order and use one connection/write lifecycle per
package unless the supported firmware transport explicitly guarantees multi-package framing. Delete
`DELETE /api/gateways/queue/message/{messageId}` only after the application's required positive
delivery acknowledgement for that exact message. Stop at the first failure; never delete the failed
message or later messages.
Logging must include lifecycle stages and non-sensitive identifiers, but never package bytes,
credentials, access codes, tokens, or decoded package payloads. Useful stages are scan ownership,
known UUID match, connection, characteristic discovery, negotiated MTU, chunk offset and length,
completion, failure, disconnection, and scan restart. Avoid per-advertisement logging; log a nameless
UUID match once per device to diagnose Android discovery without flooding the console.
UI requirements for direct communication:
```yaml
nearby_action_visibility: derived-from-current-ble-advertisement
internet_action_visibility: independent-of-ble-proximity
communication_overlay:
show_before_async-work: true
states: [connecting, transferring, success, failure]
queue_prompt:
condition: queued-messages-and-device-nearby
text: Do you want to transfer the messages to the device?
```
## 18. PUBLIC API SURFACE CATALOGUE
The production OpenAPI document is authoritative for individual request and response schemas. The
catalogue below identifies the intended runner API families and their integration role. Routes not
listed in the production OpenAPI are not supported merely because similarly named controller code
exists in another service.
### 18.1 Machine-integration API families
All routes below use bearer authentication and tenant context unless their live OpenAPI operation
explicitly says otherwise:
| Base path | Integration purpose | Important mutation semantics |
|---|---|---|
| `/api/gateways` | gateway inventory, node inventory, metering, firmware, gateway relay and queues | commands are asynchronous; verify queue/events/state |
| `/api/sensor` | sensor inventory, generic actions, claiming, pairing and communication history | action support depends on device type |
| `/api/smartlocks` | lock commands, access codes, configuration and direct packages | physical/security-sensitive; no automatic retry |
| `/api/relay` | persistent, timed and consumption-limited relay commands and packages | physical purpose and bounds must be confirmed |
| `/api/thermostat` | thermostat on/off, targets, Wi-Fi and restart | Wi-Fi and restart are operationally dangerous |
| `/api/temperature-control` | temperature statistics and history | read-only |
| `/api/node-updates` | node firmware discovery, start, abort and queue cleanup | maintenance approval required |
| `/api/events` | tenant, device and room event verification | event presence does not always prove physical completion |
| `/api/messages` | recent raw protocol-message history | in-memory, approximately 24-hour retention |
| `/api/bookings` | bookings, rooms, guest messages and room-code lifecycle | room codes are security-sensitive |
| `/api/cleaning` | cleaning rooms, settings and completion state | role-restricted |
| `/api/automation` | available automation definitions | read-only discovery |
| `/api/automation/messages` | automated-message logs and trigger lifecycle | sending side effects and recipient data require approval |
| `/api/automation/locks` | scheduled open/close/pulse tasks for locks, lock controllers and relays | superuser only; schedules unattended physical operations |
| `/api/modules` | tenant module settings | changing modules can alter available workflows |
| `/api/email-branding` | tenant email-branding configuration | validate all public URLs and sender presentation |
| `/api/lock-users` | logical lock-user lifecycle, numeric codes and MIFARE card credentials | security-sensitive; `/api/lockusers` is a compatibility alias |
| `/api/wallet-certificates` | tenant Apple/Android wallet-card metadata and phone-install artifacts | package generation is security-sensitive and does not prove phone installation or physical access |
| `/api/tenants` | tenant lifecycle | create/update are administrative; delete is destructive |
| `/api/tenants/gateway-wifi` | list reusable gateway Wi-Fi networks; save or forget an entry by exact SSID | unrestricted tenant members only; security-sensitive; synchronous storage, no gateway commands |
| `/api/users` | human users and notification registrations | account deletion and role changes are security-sensitive |
| `/api/ai` | tenant AI settings and authenticated conversation threads | may process personal data; follow retention policy |
`GET /api/messages?page=0&size=40` returns the newest 40 protocol messages for the authenticated
tenant. It requires the bearer token and `Tenant` header, has no request body, and is read-only with
no approval requirement. `page` is zero-based; `size` defaults to 40 and must be 1–100. The `200`
response retains `messages`, `newMessages`, and `latestTimestamp` and adds `page`, `totalElements`,
and `last`. Request later pages until `last` is true. Pass the first response's `latestTimestamp`
as `before` on those requests, for example
`GET /api/messages?page=1&size=40&before=1786528800000`, so newly received records do not shift
the selected time window. Deduplicate by `recordId` because records can still arrive with the same
millisecond timestamp. Optional `after` is an inclusive Unix-millisecond lower bound, useful after
clearing a client-side log. Optional `since` still changes only `newMessages` and does not restrict
the returned page. An invalid page, size, or reversed `before`/`after` range returns `400`. Reads
can be retried. Records live in memory for approximately 24 hours and are lost on process restart;
a `200` proves only that stored traffic was retrieved, not that a gateway or device acted on a
command. Check the relevant queue, event, and device state before reporting physical completion.
#### 18.1.1 Booking monetary fields
`GET /api/bookings` returns `totalAmount` and `amountPaid` as optional decimal numbers on each
booking. Both values are gross amounts expressed in the booking source system's currency; the
booking object does not currently include a separate currency code. Either value can be `null`
when the booking source does not supply the corresponding monetary data.
For Mews bookings, Solvotix first imports charged payments linked directly to the reservation. If
that total is below the booking total, it also checks charged account-level payments and attributes
one through its bill only when every order item on that bill belongs to the same reservation. A bill
containing order items from multiple reservations is not used for this fallback, because the payment
cannot be assigned to one booking safely. Order items finalized on a closed bill are treated as paid
for booking-settlement decisions. Open bills remain unpaid, including bills owned by a third-party
payer, until they are closed or a charged payment is reported.
The same optional fields are accepted and returned by `POST /api/bookings` and
`PUT /api/bookings/{bookingId}`. These routes require bearer authentication, tenant context from the
`Tenant` header, and the receptionist role. Reads are low-risk and require no approval. Creates and
updates are reversible data mutations and require confirmation of the intended booking. Do not retry
a create automatically after an ambiguous response because it is not declared idempotent; a PUT can
be retried only after checking the current booking list and confirming the target booking ID. Verify
mutations with `GET /api/bookings`. Validation failures return `400`, missing update targets return
`404`, and duplicate creates can return `409`. No command queue or physical operation is involved.
#### Booking arrival timestamp
Booking responses can include the optional `arrivedAt` timestamp. Solvotix sets `arrived=true` and
records `arrivedAt` when the booking's assigned room code is first reported as validly used by a
lock. The lock event's Unix-seconds timestamp is authoritative; server time is used only when that
event timestamp is missing or non-positive. Later uses of the same booking code do not overwrite
`arrivedAt`.
`arrivedAt` is system-owned and is not a planned check-in time. Clients must not derive it from or
write it back into `start`. Booking create and update requests do not provide a supported way to set
the arrival time. A 3RPMS check-in undo resets both `arrived` and `arrivedAt`, allowing a later valid
code-use event to establish a new arrival. Read the booking again with `GET /api/bookings` to verify
the recorded timestamp. The lock event is asynchronous, so absence of `arrivedAt` means arrival has
not yet been recorded; it does not prove the guest has not physically arrived.
#### Guest code delivery and source-system publication
Guest-message delivery and publication of an access code to a booking source system are separate
outcomes. Scheduled and booking-update automation initiate code publication only when at least
one successfully sent guest message qualifies as code delivery. An email subject or rendered body
must contain the complete current room code; an SMS must contain it in the text actually sent.
For a successfully delivered WhatsApp booking-portal template, the rendered SMS fallback text is
accepted as code-content evidence, even though that text was not sent: the linked guest portal
includes the booking code. A failed channel, simulated development delivery, staff notification,
or message content without the code does not qualify. Unsent SMS text alone is insufficient;
a successful SMS or WhatsApp delivery is still required.
Verify guest delivery with the read-only request below, using the existing bearer authentication,
`Tenant` header, and receptionist role. It has no request body and requires no mutation approval:
```http
GET /api/automation/messages/logs/bookings/<BOOKING_ID> HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```
A `200` response contains successful-delivery records; an empty result can mean an unknown booking
or no recorded delivery. Check the actual successful channel in `channels`, the matching current
`roomCode`, and that channel's message content. For a successful WhatsApp delivery, the booking's
guest-portal link counts as delivery of access to the code when `smsBody` contains the complete
current code. The `smsBody` field is accepted as code-content evidence for this check, although
it remains fallback text rather than the literal content of the delivered WhatsApp template. A `codeSent` timestamp, successful
message record, or successful code-change response alone does not prove source-system publication.
Verify the intended booking's attached access key in that system before reporting completion.
This is a security-sensitive disclosure of physical-access credentials; obtain approval for the
intended guest delivery, and never resend a guest message merely to retry an integration side effect.
Message logs and source-system attachments do not prove device upload or physical access.
Code-setting operations remain `POST /api/bookings/{bookingId}/code` with JSON
`{"code":"<ROOM_CODE>"}` and `POST /api/bookings/{bookingId}/code/refresh` without a body.
They require bearer authentication, the `Tenant` header, and the receptionist role. Confirm the
intended booking and room before these security-sensitive mutations. A `200` returns the updated
booking; `400` means the requested assignment is invalid, `404` means the booking was not found,
and explicit assignment can return `409` when the code belongs to another room or booking.
Read `GET /api/bookings` to verify the current assignment. Do not automatically retry refresh,
because another call may select another code. An updated assignment is not proof of guest delivery
or of downstream integration or device completion.
Before relying on source-system code publication, verify that the tenant PMS integration module
(`integration_pms`) and the provider-specific integration are both enabled and credentials are
configured. Retaining provider settings does not override a disabled PMS module.
Treat repeat guest messages and additional delivery channels as the same publication intent for
an unchanged booking code. Where the provider adapter maintains durable publication state,
concurrent attempts are excluded and a confirmed attachment suppresses repeat pushes; changing the
code can initiate a new publication after qualifying guest delivery. An explicitly removed attachment
must be evaluated as a new lifecycle operation. An uncertain or failed publication can require
operator reconciliation before retry. Do not clear an in-progress or reconciliation-required marker
until the remote attachment and recorded request/response history have been checked. Automatic
reconciliation, an automatic retry queue, and exactly-once behavior across all external providers
must not be assumed. Source-system failures are recorded separately from guest delivery.
#### 18.1.2 Deferring automated guest messages until a room is clean
Every booking returned by `GET /api/bookings` includes the read-only integer
`automatedMessageBlockReason`, which describes the current automated-message status:
`0 = ALL_OK`, `1 = ROOM_NOT_CLEAN`, `2 = NOT_PAID`, `3 = NO_RECIPIENT_CHANNEL`, and
`4 = DISPATCH_FAILED`. A successful delivery log is authoritative: when `messageSent` is `true`,
the list response returns block reason `0` and does not expose a stale warning from an earlier
attempt. A configured channel for which the booking has no corresponding contact detail is not
treated as an outstanding delivery after another deliverable channel succeeds. A value of `0`
does not by itself prove that a message was delivered. When both
the clean-room and payment gates block the same evaluation, the clean-room reason takes precedence.
For today's cleaning-program bookings, the list can report `ROOM_NOT_CLEAN` before the base trigger
time only when the cleaning module is active, `updateCheckInTimeOnClean` is enabled, the configured
earliest-access time has passed, a clean-room-gated `when_start_time_has_passed` guest trigger is
configured, and the booking's room is currently dirty. This early status is informational: cleaning
the room advances the booking start through the configured cleaning early-access workflow; the
status itself does not bypass that workflow. The field is system-owned and must not be written by
booking create or update clients. Re-read
`GET /api/bookings` after a trigger window or booking update to observe the current value.
`POST /api/automation/messages/triggers` and
`PUT /api/automation/messages/triggers/{id}` accept the optional Boolean
`sendOnlyWhenRoomClean`. When it is `true`, `sendToBookingGuest` must also be `true`; otherwise the
request returns `400`. Existing triggers and omitted values default to `false`.
```http
POST /api/automation/messages/triggers HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{
"triggerKey": "on_day_of_arrival",
"sendToBookingGuest": true,
"sendSms": true,
"sendEmail": false,
"sendOnlyWhenRoomClean": true,
"smsMessage": "Your room is ready.",
"timeOfDay": "15:00"
}
```
The successful `200` response is the stored trigger and includes
`sendOnlyWhenRoomClean: true`. After the trigger otherwise becomes eligible, delivery is deferred
while the booking's assigned cleaning-room record is missing, dirty, or belongs to another booking,
unless the booking is already checked in. Check-in overrides the clean-room requirement because the
guest already has access to the room. The integration processor rechecks it approximately every
minute. Once the matching room is clean or the booking is checked in, normal channel delivery and
delivery-log deduplication resume. Deferred work expires with the
trigger's existing eligibility window: arrival/departure day at local midnight, booking-created at
the booking start, start-passed/check-in at booking end, and checkout 48 hours after checkout.
This is a reversible messaging-policy change. Creating or updating a trigger requires approval
because later delivery sends guest communications. Do not automatically retry `POST` after an
ambiguous response. A `PUT` may be retried only after `GET /api/automation/messages/triggers`
confirms the target and current value. Verify configuration with that same GET route. `GET
/api/bookings` exposes the booking-level `messageSent` indication, while `GET
/api/automation/messages/logs/bookings/{bookingId}` exposes successful per-channel delivery logs.
A deferred state is not proof of delivery, and no successful delivery log is written until a channel
succeeds.
Deleting the trigger returns `204`; unknown update/delete targets return `404`. No device command
queue or physical-operation approval is involved.
#### 18.1.3 Deferring automated guest messages until a booking is paid or checked in
`POST /api/automation/messages/triggers` and
`PUT /api/automation/messages/triggers/{id}` accept the optional Boolean
`onlySendIfPaidOrCheckedIn`. When it is `true`, `sendToBookingGuest` must also be `true`; otherwise
the request returns `400`. Existing triggers and omitted values default to `false`. It is
combined with `sendOnlyWhenRoomClean`; when both are `true`, an unchecked-in booking must be both
settled and assigned to a matching clean room. A checked-in booking satisfies both gates regardless
of the recorded cleaning state.
```http
POST /api/automation/messages/triggers HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{
"triggerKey": "on_day_of_arrival",
"sendToBookingGuest": true,
"sendSms": true,
"sendEmail": false,
"onlySendIfPaidOrCheckedIn": true,
"smsMessage": "Your access details.",
"timeOfDay": "15:00"
}
```
The successful `200` response is the stored trigger and includes `onlySendIfPaidOrCheckedIn: true`.
After the trigger otherwise becomes eligible, delivery is deferred until the booking is settled or
the guest is checked in. A booking counts as settled when its `totalAmount` is above zero and its
`amountPaid` is at least `totalAmount` minus a tolerance of `2` in the booking source system's
currency; see 18.1.1 for those fields. A booking whose `totalAmount` is absent or zero is not
treated as settled and waits for check-in. The integration processor rechecks the condition
approximately every minute, and a payment or check-in arriving through a booking update is evaluated
as soon as it is stored. Once released, normal channel delivery and delivery-log deduplication
resume. Deferred work expires with the trigger's existing eligibility window: arrival/departure day
at local midnight, booking-created at the booking start, start-passed/check-in at booking end, and
checkout 48 hours after checkout. A booking that is never settled and never checked in therefore
never receives the message.
This is a reversible messaging-policy change. Creating or updating a trigger requires approval
because later delivery sends guest communications. Do not automatically retry `POST` after an
ambiguous response. A `PUT` may be retried only after `GET /api/automation/messages/triggers`
confirms the target and current value. Verify configuration with that same GET route. A deferred
state is not proof of delivery, and no successful delivery log is written until a channel succeeds;
`GET /api/automation/messages/logs/bookings/{bookingId}` exposes successful per-channel delivery
logs. No device command queue or physical-operation approval is involved.
#### 18.1.4 Read sent-message logs for one booking
Use `GET /api/automation/messages/logs/bookings/{bookingId}` to retrieve successful automated-message
delivery logs for one booking. The operation requires bearer authentication, tenant context from the
`Tenant` header, and the receptionist role. The tenant is resolved by authentication and must not be
placed in the path or query.
```http
GET /api/automation/messages/logs/bookings/booking-123 HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Accept: application/json
```
The response is a JSON array ordered newest first. A record can contain `triggerId`, `bookingId`,
`roomId`, recipient email and phone, `recipientKey`, the tenant-local dispatch date, rendered email
and SMS content, the room code present at dispatch, successfully processed `channels`, and
`createdAt`. An unknown booking ID and a booking with no successful delivery records both return
`200` with an empty array; the endpoint does not disclose whether a booking exists in another
tenant.
This is a read-only, idempotent operation with no queue or physical side effect. No approval is
required to read it, but the response contains personal contact data, message content, and possibly
an access code. Minimize display and retention, never expose it to a booking guest, and do not log the
response. Automatic retry is allowed after a definite transport failure. Verify message delivery by
checking for the expected channel in `channels`; a trigger firing or a missing record is not proof of
delivery. Authentication failures return `401` and insufficient role access returns `403` according
to the shared security filter.
#### 18.1.5 Room-code health
`GET /api/bookings/rooms/{roomId}/codes/health` returns a read-only health table for every stored
room code and every code-capable lock assigned to the room. It requires bearer authentication,
tenant context from the `Tenant` header, and the receptionist role. Access codes and booking IDs are
security-sensitive; do not put the response in logs or analytics.
```http
GET /api/bookings/rooms/room-101/codes/health HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```
```json
{
"roomId": "room-101",
"roomName": "Room 101",
"status": "DEGRADED",
"allCodesUploadedToAllLocks": false,
"codeCount": 1,
"codeCapableLockCount": 2,
"unresolvedSensorIds": [],
"codes": [
{
"code": "4827",
"taken": true,
"takenByBookingId": "booking-123",
"valid": true,
"uploadedToAllLocks": false,
"uploadedLockCount": 1,
"requiredLockCount": 2,
"locksMissingCode": ["lock-2"],
"locks": [
{"lockId": "lock-1", "lockName": "Front door", "uploaded": true, "codeStatus": "UPLOADED"},
{"lockId": "lock-2", "lockName": "Side door", "uploaded": false, "codeStatus": "PENDING_UPLOAD"}
]
}
]
}
```
A lock is counted as uploaded only when the backend slot state is `ADDED_TO_LOCK` and a positive
delivery acknowledgement has populated `uploadedToLockAt`. Slot states are rendered as `UPLOADED`,
`MISSING`, `PENDING_UPLOAD`, `PENDING_REMOVAL`, `PENDING_CONFIRMATION`, or `UNKNOWN_STATE`.
Non-code-capable room sensors are excluded. Missing assigned sensor records appear in
`unresolvedSensorIds` and prevent an overall healthy result. Overall status is `HEALTHY`,
`DEGRADED`, `NO_CODES`, or `NO_CODE_CAPABLE_LOCKS`.
This endpoint does not refresh a lock, enqueue commands, or repair missing codes. It is idempotent,
low-risk apart from disclosure of access credentials, requires no approval for an authorized health
view, and may be retried after failure. A missing room returns `404`; an empty room ID returns `400`.
Use the same endpoint after an independently approved resync operation to verify convergence. Do not
treat queued or pending upload state as physical completion.
#### 18.1.6 Automatic room-code digit exclusions
`PUT /api/bookings/rooms/code-generation-settings` applies one automatic code-generation policy to
one or more rooms in the current tenant. It requires bearer authentication, tenant context from the
`Tenant` header, and the receptionist role. This changes physical access credentials and therefore
requires explicit operator approval before submission.
```http
PUT /api/bookings/rooms/code-generation-settings HTTP/1.1
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{
"roomIds": ["room-a", "room-b", "room-c", "room-d"],
"excludedDigits": ["5"]
}
```
`roomIds` must contain at least one non-blank room ID. `excludedDigits` may be empty to clear the
room policy; otherwise every value must be one decimal digit from `"0"` through `"9"`. Duplicate
values are collapsed. At least two digits must remain available, so at most eight distinct digits
can be excluded. The endpoint validates that all selected rooms exist before changing any room.
Tenant is authentication context and must never be supplied in the body or as a query parameter.
```json
{
"roomsUpdated": 4,
"codesRemoved": 2,
"replacementCodesCreated": 2,
"activeCodesRetained": 1,
"rooms": [
{
"roomId": "room-a",
"excludedRandomCodeDigits": ["5"],
"codes": []
}
]
}
```
Saving removes every unused stored room code containing an excluded digit, persists compliant
one-for-one replacements, and queues removal and addition commands for every code-capable lock
assigned to each room. A conflicting code assigned to an active booking is retained so an occupied
guest is not locked out; `activeCodesRetained` reports these exceptions. When such a booking later
releases its code, the backend removes and replaces it instead of returning it to the available
pool. Subsequent automatic generation, automatic booking assignment, pool replenishment, refresh,
and scheduled reconciliation all enforce the room policy. Explicit operator assignment remains a
manual action rather than random generation, but a conflicting unused code is ineligible for
automatic selection.
New replacement codes use the tenant's `roomCodeLength` (4 by default). Existing codes of another
length remain until changed separately, but automatic selection skips them; locks with code slots of
another length can reject uploads.
The response confirms database persistence and command queueing only. It does not prove that an
offline or delayed lock has applied removals or additions. After saving, verify each room with
`GET /api/bookings/rooms`, then use `GET /api/bookings/rooms/{roomId}/codes/health` until expected
codes are confirmed on every required lock and removed codes no longer remain. Do not expose code
values in logs. Invalid input returns `400`, a missing selected room returns `404`, and inability to
produce a compliant unique replacement returns `409`. Repeating the same completed request is
idempotent. After an ambiguous response, read the room settings and code health before deciding
whether to retry.
### 18.2 Browser and guest API families
```yaml
browser_session:
base_path: /api/auth/session
credential: Firebase ID token exchanged for an HttpOnly cookie
api_user_token_supported_for_cookie_creation: false
guest_portal:
base_path: /api/guest-portal
interactive_authentication: booking-scoped guest context implemented by the portal workflow
Tenant_header: not a substitute for booking authorization
warning: may expose room, access, checkout, offer, direction, and guest-AI data
guest_portal_administration:
base_path: /api/guest-portal-admin
authentication: bearer plus tenant context
```
Guest portal room messages are configured through the authenticated admin settings endpoint.
Each message may use `roomIds` to apply to multiple rooms. Omit `language` to make that message
the all-language default for those rooms; a message for the requested language takes precedence.
If no room message matches, the configured general message is returned.
Automated-message create/update requests likewise accept `roomMessages`; each entry can contain
`roomIds`, optional `language`, and general, SMS, or email instruction text. `{roominstructions}`
first resolves the raw matching room-specific message from guest-portal settings, without the
guest portal general wrapper. A matching trigger `roomMessages` entry is the fallback. Omitting
`roomMessages` from a legacy update preserves existing room templates and legacy marker-based
messages continue to work.
Guest-facing feature toggles come from
`GET /api/guest-portal/bookings/{bookingId}/display-settings?tenantId={tenantId}`, inside the same
booking-scoped guest workflow. It returns only `showRoom`, `showCode`, `showOpenButton`,
`showCheckoutButton` and `limitToValidTime`, is read-only, and answers `404` for a booking that
cannot be resolved. Guest clients must use this endpoint and never `/api/guest-portal-admin/settings`,
which requires an authenticated tenant session and therefore always fails for a guest. Fail closed
when it is unavailable: treat every `show*` toggle as `false`, and `limitToValidTime` as `true`.
These toggles control presentation only — the server independently enforces each one and answers
`403` regardless of what a client displays.
Guest checkout uses `POST /api/guest-portal/bookings/{bookingId}/checkout?tenantId={tenantId}`
inside the booking-scoped guest workflow. The request has no body. It requires an existing booking
with a room, an enabled checkout button, and a currently valid guest-portal booking window. Success
marks the local booking checked out, moves its end to the short checkout grace window, and marks the
room dirty. For a provider-backed booking that was checked in, success also schedules an immediate,
durable checkout reconciliation with the source provider; the lifecycle monitor retries transient
provider failures independently of room-code removal. A successful HTTP response confirms the local
checkout only, not provider completion. Verify provider completion through the subsequent booking
state (`checkedOut=true`) from provider synchronization and the booking checkout-transition event.
Repeated checkout requests are rejected after the local transition, so do not retry a successful
response. Treat checkout as a high-impact state change requiring explicit guest intent. Expected
failures are `403` when the room, setting, or booking window does not permit checkout and `404` when
the booking or room cannot be resolved.
Do not treat possession of a booking ID as a general authorization scheme outside the published
guest-portal workflow. Do not expose room codes, booking identifiers, AI conversations, or guest
data in logs or analytics.
### 18.3 API-user management boundary
`/api/api-users` is for authenticated interactive tenant members. A `sat_` API user cannot list,
create, rotate, or revoke API users. `Tenant` is required. Creation and rotation return the plaintext
token exactly once; rotation invalidates the old token immediately; revocation disables it.
### 18.4 APIs outside the runner contract
CRM controllers, PMS/provider integration controllers, incoming webhook routes, internal notification
routes, the webhook forwarding gateway, and the virtual-gateway simulator are separate service
surfaces. They are not part of the `https://backend.solvotix.org/v3/api-docs` runner contract unless
they appear in that live document. Provider callbacks and `/internal/**` routes must never be called
as ordinary tenant API operations. Use the provider-specific deployment contract for OAuth state,
webhook authentication, retries, deduplication, and offboarding.
The integration-process service exposes the read-only `GET /integrations/logs` operation for its
webhook and outgoing-request activity. This is an integration-process contract, not a runner
`/api/**` operation. An authorized caller can request, for example,
`GET /integrations/logs?from=2026-09-14T00%3A00%3A00Z&to=2026-09-14T12%3A00%3A00Z&page=0&size=40`.
`from` and `to` are optional inclusive ISO-8601 instants; without them, the range starts at the
beginning of time and ends at request time. `page` starts at 0; `size` defaults to 40 and must be
1–100. Optional `search` filters payload and request-data text before paging, using a literal,
case-insensitive substring of at most 200 characters. The `200` response is a Spring page with `content` (log records), `number`, `size`,
`totalElements`, and `last`, ordered by `receivedAt` and ID descending. Continue while `last` is
false. An invalid page, size, or reversed range returns `400`; malformed timestamps also return
`400`. This endpoint only reads stored logs, needs no approval, and can be retried. Use one fixed
`to` value for successive pages and deduplicate IDs after a refresh because new records can shift
offsets. A successful response verifies only that stored records were retrieved, not that an
integration request succeeded or that an external system applied it. Inspect the relevant
integration state for that conclusion. Log payloads and headers can contain sensitive data; do
not expose them beyond the authorized log viewer.
### 18.5 Mews cleaning reconciliation
`POST /integrations/mews/cleaning/sync` performs an immediate one-way reconciliation from Mews into
the tenant's built-in cleaning program. It requires authenticated access and the `Tenant` header,
has no request body, and returns `204 No Content` after the Mews resources have been fetched and
applied. Disabled or incomplete Mews configuration returns `400`; authentication failures return
`401`; upstream Mews or
persistence failures are reported as server errors.
```http
POST /integrations/mews/cleaning/sync HTTP/1.1
Authorization: Bearer <authenticated integration token>
Tenant: <TENANT_ID>
```
Mews is the master. `Clean` and `Inspected` mark a built-in room clean, while `Dirty` marks it dirty.
`OutOfService`, `OutOfOrder`, and unknown states do not change the built-in cleaning flag. The
operation never writes a cleaning state back to Mews. It is an idempotent reconciliation and may be
retried after a definite failure, but do not overlap concurrent runs. Its risk classification is
`reversible`; approval is not required when the tenant has already configured Mews as its cleaning
master. Verify the result with `GET /api/cleaning/rooms` or
`GET /api/cleaning/rooms/{roomId}`. Normal synchronization also runs at service startup, every five
minutes, and from Mews Resource WebSocket events when those events are available.
After each successfully applied or confirmed Mews `Clean` or `Inspected` result, the integrations process asks
the runner to apply its existing early-access rule for that room. This uses the private
`POST /api/internal/cleaning/rooms/{roomId}/apply-early-access` process-to-process operation with the
shared internal token and tenant context. It has no request body and returns `204` after evaluating
the rule. The operation does not itself mark a room clean: it only advances an eligible local
booking's start time according to the tenant cleaning settings. It is not a public tenant API and
must not be called with an API-user or Firebase token. A failed internal call is not treated as a
failed Mews state import; a later full reconciliation retries the evaluation.
### 18.6 Mews booking synchronization
`POST /integrations/mews/sync` imports recent Mews reservation changes and all stays that overlap
the tenant's current local calendar day. It requires authenticated access and the `Tenant` header,
has no request body, and returns `202 Accepted` with no response body after the synchronization call
has completed.
```http
POST /integrations/mews/sync HTTP/1.1
Authorization: Bearer <authenticated integration token>
Tenant: <TENANT_ID>
```
The optional `updatedSince` and `updatedTo` query parameters are ISO 8601 timestamps. By default,
the update selection ends at the current time and starts at the configured lookback, normally 48
hours. The effective update start can never be more than 48 hours before the current time, even if
an older `updatedSince` is supplied, and a future `updatedTo` is capped to the current time. An
invalid or blank timestamp is treated as omitted.
Independently of that update selection, every call also requests Mews reservations whose stay
interval collides with midnight-to-midnight today in the tenant's configured IANA time zone. This
means a current stay is synchronized even when its last Mews change was more than 48 hours ago.
Reservations returned by both selections are de-duplicated by Mews reservation ID before they are
applied.
```http
POST /integrations/mews/sync?updatedSince=2026-08-24T08:00:00Z&updatedTo=2026-08-25T08:00:00Z HTTP/1.1
Authorization: Bearer <authenticated integration token>
Tenant: <TENANT_ID>
```
Mews settings must already exist for the tenant or the operation returns `400`. Disabled or
incomplete settings result in an accepted no-op. Authentication failures return `401`; upstream
Mews and persistence failures are reported as server errors. Treat this as a state-changing,
reversible provider reconciliation: no additional approval is required after the tenant has
configured Mews, but do not start overlapping runs. A retry is allowed after a definite failure.
The reservation upsert is keyed by the Mews reservation ID, canceled reservations are removed, and
the same reservation is applied only once per run. After success, verify the affected stay through
`GET /api/bookings`, including its dates, room, guest identity, payment state, and checked-in or
checked-out state before relying on downstream room-code, messaging, cleaning, or access behavior.
## 19. ERROR POLICY
```yaml
http_400:
meaning: invalid request or payload
action: validate against live OpenAPI
retry_unchanged: false
http_401:
meaning: missing, invalid, expired, revoked, or tenant-mismatched API token
action: stop and request API-user verification or rotation from the system owner
automatic_retry: false
http_403:
meaning: authorization or tenant access denied
action: verify roles and selected tenant
bypass: forbidden
http_404:
meaning: endpoint or resource unavailable
action: refresh OpenAPI and inventory
http_409:
meaning: state conflict
action: inspect current state before resolution
http_5xx:
meaning: server failure
read_retry: exponential backoff permitted
physical_command_retry: forbidden until queue and events are inspected
network_failure_after_send:
state: ambiguous
action: inspect queues and events before any retry
```
## 20. RISK AND APPROVAL MATRIX
```yaml
read_only:
examples: [inventory, state, history, metering, queues]
default_agent_permission: execute
reversible:
examples: [temperature target, ordinary short pulse]
default_agent_permission: confirm target and bounds
security_sensitive:
examples: [access codes, users, permissions]
default_agent_permission: require explicit approval
destructive:
examples: [delete device, tenant, codes, queue data]
default_agent_permission: require explicit confirmation
ownership_changing:
examples: [claim gateway, claim sensor]
default_agent_permission: require explicit approval
operationally_dangerous:
examples: [persistent relay, persistent lock, Wi-Fi, firmware, scheduled lock automation]
default_agent_permission: require purpose, safe conditions, and explicit approval
```
## 21. SECRET HANDLING
Never expose or log:
```yaml
secrets:
- Solvotix sat_ API token
- private key
- Solvotix session cookie
- Wi-Fi SSID when classified as private
- Wi-Fi password
- smart-lock access code
- any internal API token
```
Use redaction markers such as `<REDACTED_TOKEN>` and `<REDACTED_ACCESS_CODE>`.
## 22. COMPLETION CRITERIA
Do not report the integration complete until all applicable statements are true:
```yaml
completion:
- live OpenAPI retrieved and validated
- generated or typed client matches project conventions
- API token is loaded only from a protected secret source
- API token begins with sat_
- bound tenant ID is explicit
- read-only authentication check succeeds
- gateway inventory loads
- device inventory loads
- device capabilities are mapped from type and OpenAPI
- secrets are redacted
- physical commands are approval-gated
- non-idempotent commands are not automatically retried
- accepted, queued, delivered, and confirmed states remain distinct
- queue and event verification is implemented
- direct BLE discovery does not depend on the local name or backend online status
- direct BLE delivery uses the documented service, characteristic, and MTU-safe chunking
- scanning stops for connection and resumes after disconnection
- queue messages are removed only after the required positive delivery acknowledgement
- failure and ambiguity are surfaced to the caller
```
## 23. BOOTSTRAP PROMPT
```text
Integrate this project with Solvotix. First retrieve and read https://solvotix.net/ai-first/agent-manifest.json, https://solvotix.net/ai-first/agent-guide.md, and https://backend.solvotix.org/v3/api-docs. Read all three before editing code. The system owner creates the Solvotix system at https://portal.solvotix.org/login and creates a tenant-bound API user at https://portal.solvotix.org/home/settings#system-users. Use the copied sat_ token only from a protected server-side secret and send it as Authorization: Bearer sat_<TOKEN> with the bound Tenant header. Do not implement interactive user authentication for the integration. Identify the project's language, architecture, existing HTTP client, and code-generation conventions. Implement typed API access, gateway discovery, device discovery, and queue/event verification. Treat hardware commands as asynchronous. Never report physical success solely from an accepted or queued response. Never log tokens, Wi-Fi credentials, access codes, or private customer data. Begin with read-only discovery. Require explicit approval for physical, security-sensitive, destructive, ownership-changing, Wi-Fi, and firmware operations. Do not automatically retry non-idempotent physical commands. If the guide, OpenAPI, inventory, and observed state disagree, stop and report the conflict instead of guessing.
```
## 24. EMAIL BRANDING, TEMPLATES AND UPLOADED LOGOS
Navigate to **Settings → Messaging → Email branding** (`/home/settings#messaging`).
Choose **Email template**, use **Upload logo** or the optional **Logo URL**, edit the brand
and contact fields, inspect **Email preview** in **Mobile view** or **Desktop view**, then
select **Save email branding**. The preview uses unsaved form values and representative
content; it sends no email. Actual email-client rendering can differ.
The existing operations are `GET /api/email-branding` and `PUT /api/email-branding`.
Both use bearer authentication and tenant context supplied globally by the authentication
filter, with no tenant path or query argument. Machine clients send
`Authorization: Bearer sat_<TOKEN>` and `Tenant: <TENANT_ID>`; the token is tenant-bound.
Human clients use the normal portal authentication and selected tenant. The shared role
policy allows unrestricted (empty-role) identities and `Restricted`; other role-limited
identities receive `403`. These operations do not change authorization or SMTP settings.
GET returns `200` with `EmailBrandingSettings`, creating an empty settings record if absent.
PUT returns `200` with the persisted settings, including the uploaded logo. Read before
writing to preserve the current contact fields and confirm the selected tenant.
```http
PUT /api/email-branding
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{
"template": "modern",
"name": "Example Hotel",
"logoUrl": "https://example.com/logo.png",
"logoBase64": "",
"website": "https://example.com",
"addressLine": "Example Street 12",
"supportEmail": "reception@example.com",
"supportPhone": "+47 22 55 88 99"
}
```
This example uses the URL and removes a previously uploaded logo. To upload instead, set
`logoBase64` to a complete `data:image/png;base64,<BASE64_IMAGE_BYTES>` or
`data:image/jpeg;base64,<BASE64_IMAGE_BYTES>` data URI. The placeholder is not a valid image;
encode the actual file bytes. No multipart upload or separate upload endpoint is used.
| Field | Meaning and update behavior |
|---|---|
| `template` | `classic` (centered, framed), `modern` (teal, left-aligned header), `minimal` (unframed), or `elegant` (navy header, serif branding, orange accent). Omitted/null preserves the stored selection; blank resets to `classic`. Missing or unknown legacy stored values render as Classic. Unknown nonblank values on PUT return `400`. |
| `logoBase64` | Valid PNG/JPEG data URI, at most 524288 decoded bytes, and 1–2048 pixels on each side. Media type and image content must match. Omitted/null preserves the stored upload; empty/blank removes it. GET and PUT return the stored data URI. |
| `logoUrl` | Optional absolute HTTP(S) URL without credentials, used only when no valid uploaded logo exists. A recipient can block remote image loading. |
| `name`, `addressLine`, `supportEmail`, `supportPhone` | Escaped display text in the header/footer; contact fields do not set From or Reply-To. |
| `website` | Optional absolute HTTP(S) URL without credentials, linked in the header. |
Existing text and URL fields are replaced on PUT; omitted/null/blank values clear them.
`id`, `tenantId` and ownership metadata are server-managed for this operation and are not
used to select another tenant. Surrounding whitespace is trimmed. The frontend's **Remove
logo** clears both the upload and logo URL; clearing only `logoBase64` through the API
allows a saved `logoUrl` to appear again.
Uploaded logos take precedence over logo URLs. The branded mail service decodes the saved
image and embeds it in a `multipart/related` MIME message, referenced as `cid:branding-logo`.
The outgoing HTML does not contain a base64 data URI and the uploaded image does not need
remote hosting. Existing settings without a selected template use Classic. Invalid legacy
stored image data is ignored so it does not prevent sending the message body. The message
content is retained inside the chosen layout. The footer includes saved contact details and
the Solvotix attribution. This applies to future messages through the shared branded mail
renderer in runner and the automated-message integration process; saving does not resend previous messages or send a preview message.
Risk: reversible tenant-wide presentation change. Confirm the intended tenant and branding;
existing authorization to edit branding is sufficient. No physical-operation approval is
needed. GET is safe to retry. Repeating PUT with the same complete values is idempotent,
with last-write-wins behavior and no version check. After an ambiguous save, GET and compare
before retrying so a newer edit is not overwritten. No device queue entry, hardware operation
or branding event is created. Verify persistence with GET. To verify actual inbox rendering,
use a separately authorized existing email-sending workflow and inspect the received message;
the preview and successful save do not prove delivery.
Errors: `400` for missing tenant context, malformed JSON, an unsupported template, invalid
image data, excess image size/dimensions, or invalid URLs; `401` for failed authentication or
API-token tenant mismatch; `403` for denied role or tenant access. Correct invalid input before
retrying. On server or network failure, follow the read-before-retry policy. No events or
additional event input keys are required by these settings operations.