DOCUMENT_STATUS=READY
ASK THE GUIDE
Solvotix website assistant
Ask about products, specifications, solutions, integrations, and the API. Answers include links to the website and documentation.
Solvotix saves questions and replies for later review. Your question and recent messages are sent to OpenAI to answer from the documentation. Do not include API tokens, passwords, access codes, or customer data. AI answers can contain mistakes; check the linked sources. This assistant provides guidance only and cannot operate devices.
Read the agent guide---
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
```
### Sensor deletion and delayed-code warnings
`DELETE /api/sensor/{id}` removes the global sensor security association, the tenant's
sensor record, and all backend code-slot records for that device (PIN, MIFARE and wallet,
including pending uploads/removals). Require explicit approval: this is destructive and
changes ownership/security tracking. Confirm the current owning tenant and device ID
using inventory and ownership discovery before deletion. Authentication requires a
bearer token with unrestricted or Restricted access; other role-restricted identities
receive `403`. Tenant context is applied globally by the authentication filter.
```http
DELETE /api/sensor/<DEVICE_ID>
Authorization: Bearer sat_<TOKEN>
Tenant: <CURRENT_TENANT_ID>
```
No request body is required. Success is `204 No Content`, including when the sensor is
already absent; retrying deletion also removes leftover code-slot records. It does not
clear credentials on the physical lock, send a removal command, cancel previously queued
commands, remove room/lock-user references, or emit a deletion event. If physical credential
revocation is required, complete and verify that separately before deleting the device.
Verify backend removal by checking that the ID is absent from `GET /api/sensor`.
Deletion is idempotent only while the device remains unclaimed and unchanged. The database
steps are not transactional. After a timeout or server error, inspect inventory and ownership
before an explicitly authorized retry; never repeat deletion automatically after a new claim.
Do not retry `401`/`403` without correcting authentication, tenant context or permissions.
The delayed-code warning monitor excludes slots whose device is absent from tenant inventory.
Existing orphaned slots are ignored, not automatically deleted by the monitor. Warnings for
existing devices still share the tenant's once-per-24-hours limit.
### Gateway offline warning delay
`PUT /api/gateways/{id}` accepts the gateway object with
`pushNotificationDelayMinutes` set to a positive number of minutes. Authenticate
with a tenant-bound bearer token and `Tenant` header. First read
`GET /api/gateways/{id}`, then send the returned gateway object with the desired
value. The URL `id` selects the gateway; a body `id` is ignored. For example:
```http
PUT /api/gateways/<GATEWAY_ID>
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{"id":"<GATEWAY_ID>","name":"<EXISTING_NAME>","description":"<EXISTING_DESCRIPTION>","pushNotificationDelayMinutes":20}
```
The update returns the gateway object (`200`), or `404` if the gateway is absent.
Include the existing editable gateway fields in the request because this operation
also updates gateway metadata. A missing, null, zero or negative delay uses five
minutes. The previous fixed gateway offline cutoff was four minutes. The delay
starts from the last received gateway heartbeat. An offline state change creates
a gateway offline event and sends the usual warning; a heartbeat before the delay
expires prevents that offline transition. The monitor has a five minute startup
grace period. This is a low risk configuration change with no separate approval
requirement. Read the gateway again to verify the stored value. Repeat the update
only after reading state following an ambiguous response; no command queue or
hardware acknowledgement is involved. Correct authentication or tenant context
before retrying `401` or `403`.
### Lost gateway replacement request
`POST /api/gateways/{id}/replacement-request` sends one email to Solvotix support
(`boggibill@gmail.com`) with the subject `New gateway request` and the authenticated
tenant's stored name, the gateway's stored name, its description, and the verified
gateway ID. The URL `id` must identify a gateway registered to the current tenant.
The server looks up these values; there is no request body, and clients cannot
supply the email content or recipient. Missing names and descriptions are shown
as placeholders.
```http
POST /api/gateways/<GATEWAY_ID>/replacement-request
Authorization: Bearer <AUTHORIZED_TOKEN>
Tenant: <CURRENT_TENANT_ID>
```
This is a user-requested external notification. Confirm the selected tenant and lost
gateway before sending. `204 No Content` means the mail service accepted the message;
it does not prove that the recipient received it or that a replacement was ordered.
A missing gateway, wrong tenant, or missing tenant name returns `404`. A mail
transport failure returns `5xx`. The endpoint does not remove the gateway or change
its registration. Do not retry automatically after an ambiguous response, because
another email could be sent. Gateway removal requires a separate user confirmation,
`DELETE /api/gateways/{id}`, and the verification described below. If the email
request fails, leave the gateway registered.
### 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",
"contactEmail": "contact@example.com",
"diagnosticEmail": "diagnostics@example.com",
"contactPhoneNumber": "+4512345678",
"contactName": "Jane Smith",
"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 optional `contactEmail`, `diagnosticEmail`, `contactPhoneNumber`, and `contactName`
fields store tenant contact details. They are returned by `GET /api/tenants` and saved by
`PUT /api/tenants`; changing them does not queue device messages. Read the tenant back to verify
these fields after saving. 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.
For backend administrator invitations and booking emails, tenant SMTP connection, read, and write
timeouts are each 60 seconds (not a total request deadline). On a socket timeout, the backend
makes one fallback attempt through its configured global mail transport, unless the error reports
that delivery already succeeded. The fallback uses the application-configured `mail.from` address,
not the tenant's `defaultFromName` or `smtpFrom`, and sets Reply-To to the original From address.
Recipients, subject, body, and branding are preserved. Other failures, including authentication
or recipient rejection without a timeout, do not trigger this fallback. A failure of the global
transport is propagated to the caller; there is no recursive fallback. This policy does not apply
to CRM mail. A timeout can occur after a provider accepted a message but before its acknowledgement
arrived, so fallback can produce a duplicate; it does not guarantee exactly-once delivery or inbox
receipt. Check application delivery records and the received From/Reply-To headers when verifying
fallback. The fallback is part of an already-authorized send, not a new invitation or API operation.
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 one of `sensorUuid=<uuid>`, `roomId=<id>`, or `bookingId=<id>` to filter; these filters
cannot be combined, and room queries require the time range. A supplied `bookingId` must not be
blank. 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. This paginated endpoint allows `Receptionist`,
`Restricted`, or unrestricted access (an empty role list). Receptionist responses censor access codes
for all page filters, including room queries. Missing or invalid authentication returns 401; a role
without access returns 403. Correct authentication or permissions before retrying those failures.
This is a low-risk read with no request body, device command, or queue change. It has no physical
effect, needs no approval, and is idempotent and 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.
To read the recorded timeline for one booking, use its `Booking.bookingId`, not its database
`id`, and authenticate in that booking's tenant:
```http
GET /api/events/page?bookingId=booking-123&page=0&size=40
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```
There is no request body or tenant query argument. Optional `from` and `to` bounds select event
creation time and must be supplied together. Direct matches use exact `data.bookingId` in the
tenant's events. The endpoint also finds otherwise unlinked access/code events by codes recorded
in the booking's retained directly linked events. The union is deduplicated before database
pagination, and optional `from`/`to` bounds apply to both kinds of result. No current booking
lookup is required: retained events and code history for a deleted booking remain usable. An unknown booking ID
or a booking with no matching events returns 200 with an empty `content` array and
`totalElements: 0`, rather than 404. A blank booking ID, conflicting filters, invalid page/size,
or invalid date bounds returns 400; correct the input before retrying.
Results may contain booking creation (1033), update or checkout (1034), deletion (1035), sent
messages (1030), and device events that carry that booking ID. Code correlation additionally
includes credential-result events (action 33, sub-actions 8/9/12/13) and code add/remove events
(1028/1029) without a saved booking link. For lock action 33, sub-actions
8/12 report accepted PIN/extended credentials and 9/13 report rejected credentials. Interpret
`data.valid` as a string; other credential metadata may be absent. Accepted credentials do not
prove physical entry. Use the event registry below for the other action and metadata meanings.
Code discovery reads `data.code` from all retained directly linked booking events, including
events outside the requested date range or page. Leading zeros are preserved and codes match
exactly. Inferred events must have `createdDate` within at least one inclusive `data.start` /
`data.end` stay window recorded in those linked events. All valid recorded stay windows are
considered, so earlier snapshots can cover an access event even if checkout later shortened
the booking. Missing, malformed, or reversed stay bounds are ignored; without any valid window
or recorded code, only directly linked events are returned. Events already linked to a different
booking are excluded from inference. Code correlation spans the tenant's devices and has no
additional device filter. Reuse of the same code within a recorded stay window can still make
inferred attribution ambiguous; do not treat it as certain guest identity or proof of entry.
The gateway already attempts to save `data.bookingId` on credential-use events by resolving the
PIN through its current room-code booking assignment. Saved links remain the primary association.
The read-time fallback exposes historical unlinked events without rewriting them or adding
`data.bookingId`; clients must not discard a returned event merely because that field is absent.
This is a partial recorded timeline: other unlinked events, integration request logs, and
`booking_history` snapshots are not merged into the page. Booking update events contain selected snapshot values,
not a complete list of field changes with old and new values. An empty result does not prove
that no changes or access attempts occurred. Apply the same role permissions and Receptionist
code censoring described above; contacts, message bodies, and credential identifiers may still
be sensitive. This low-risk read needs no approval, has no physical effect or queue entries,
and is idempotent. Retry transient read failures with bounded backoff and deduplicate event IDs.
Verify retrieval through `content`, `totalElements`, and `last`, then request subsequent pages
until `last` is true; use fixed bounds for historical reads. Verify physical operations separately
through device state and the operation's documented confirmation events.
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/bookings` and
`PUT /api/bookings/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/bookings`. Validation failures return `400`, missing update targets return
`404`, and duplicate creates can return `409`. No command queue or physical operation is involved.
#### Reopening a checked-out booking
In Property management, edit the intended booking, clear **Checked out**, review the dates
and check-in state, and save through the existing receptionist operation:
`PUT /api/bookings/bookings/{bookingId}`. It requires authenticated tenant context and
explicit operator intent. Send the reviewed booking fields; this is not a partial checkbox patch.
A persisted `checkedOut=true` to submitted `false` transition clears the previous guest departure
flag, checkout request metadata, provider-sync attempt status/counters/timestamps/error, guest
rollback fields and deferred cleaning marker. The submitted `end` and `checkedIn` still apply;
reopening does not reconstruct an original departure time, change the room's current cleaning
state, or undo provider/device actions already sent. Normal access, automation and provider
rules continue to apply. A checkout already in flight is not cancelled by this edit, and an
existing whole-booking provider save can race with the update.
Re-read the booking list and booking-scoped guest validity after saving. When access is allowed,
the guest may explicitly report departure again: each new cycle stores its current end for
cancellation and schedules a fresh five-minute deadline. The closed portal checks validity every
five seconds and reopens when allowed. Editing a pending report or leaving `checkedOut=true`
preserves its report and provider-sync state; duplicate pending/completed guest reports still
return `403`. Do not automatically repeat an ambiguous mutation. Check persisted state first.
A successful reopen logs the existing booking update event `1034` with `data.state=updated`;
event persistence is separate from the booking save and neither proves physical delivery.
#### 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. Message-driven automation initiates its code-publication callback 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 or the guest-portal URL for the same tenant and booking;
an SMS must contain either in the text actually sent. The `{guestportal}` placeholder renders to
`https://portal.solvotix.org/guestportal/<TENANT_ID>/<BOOKING_ID>` and qualifies like `{code}`,
including an HTML email link, because the portal provides access to the booking code. An unresolved
placeholder or another booking's portal URL does not qualify. The booking must have a current code.
For a successfully delivered WhatsApp booking-portal template, the rendered SMS fallback text is
accepted as code-content evidence when it contains the current code or matching portal URL, even
though that text was not sent. A failed channel, simulated development delivery, staff notification,
or message content without either the code or matching portal URL 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 or the portal URL for the same tenant and booking. For email and SMS, the successfully
sent content can likewise contain either the current code or that matching portal URL. 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 when the adapter publication prerequisites are satisfied. 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.
#### Additional code publication on check-in
Guest-message delivery remains an independent source-system publication trigger: a successfully
processed email, SMS, or WhatsApp message containing the current code or the matching Guest Portal
URL can publish the code whether or not the booking is checked in.
Supporting provider adapters can also publish the current code when the
`push_codes_on_check_in` booking filter is present and the booking is checked in. This filter takes
no variables. Guest-message delivery is not a prerequisite for this additional check-in path. Do
not assume every provider supports it.
Read `GET /integrations/booking-filters`, preserve all intended rules, then include this rule in
the complete list sent to `POST /integrations/booking-filters`:
```json
{"filterType":"push_codes_on_check_in","variables":{}}
```
Both operations require an authenticated integration-service session or accepted bearer identity
and the `Tenant` header; do not assume main-API machine tokens are accepted by this service.
The POST body is an array and replaces the entire list. This is a security-sensitive disclosure
of access credentials; obtain operator approval before enabling it. Success returns `200` and
the saved list; missing tenant or invalid filter input returns `400`, and missing or invalid
authentication returns `401`. Read back the list before retrying an uncertain save.
Publication requires an enabled PMS module and provider integration, configured credentials,
a persisted current code, `checkedIn=true`, and `checkedOut=false`. Booking synchronization
(including unchanged bookings) and supported code-use check-in handling evaluate the policy.
Existing checked-in bookings become eligible on subsequent synchronization; saving filters alone
does not publish codes. A code that becomes available later can be published on a later sync.
Removing the rule stops future check-in-triggered attempts but does not disable message-triggered
publication, revoke an existing attachment, or cancel an in-flight request. No guest message needs
to be sent merely to initiate the additional check-in path.
Durable publication state suppresses confirmed repeat pushes for the same booking and code and
excludes concurrent attempts. A changed code is a new publication intent. An uncertain provider
mutation remains blocked for operator reconciliation; there is no automatic reconciliation queue.
Verify the current local code and check-in state through `GET /api/bookings`, then verify the
attachment on the intended booking in the source system. A filter-save response, guest-message
log, or local booking event does not prove publication, device upload, or physical access.
#### Arrival-time message eligibility and optional expiry checkout
For the existing `when_start_time_has_passed` message trigger, an unchecked-out booking becomes
eligible when its stored `start` plus `offsetMinutes` is reached and remains eligible until its
`end` (if supplied) or checkout. The optional integer `offsetMinutes` defaults to `0` when omitted
or null on create or update; existing triggers also default to `0`. For example, `-60` makes the
message eligible one hour before start, `60` one hour after, and `0` at start. Other trigger types
ignore this field. An offset does not extend the booking end or checkout cutoff; if the threshold
is at or after booking end, the message cannot send.
Reservations imported before the offset threshold wait until that time; reservations imported or
updated after the threshold are evaluated immediately while the stay has not ended or checked out. The periodic processor also
re-evaluates eligibility, normally about once per minute. Neither booking creation time nor the
message rule's modification time excludes an otherwise eligible booking. This works across midnight
and uses elapsed minutes across timezone changes. Negative offsets can select bookings whose start
is still in the future. Delivery follows the existing periodic schedule and recipient gates, so the
offset defines eligibility rather than an exact delivery guarantee.
Existing recipient, payment and room-cleanliness requirements still apply. Successful delivery
records suppress already delivered channels for the same trigger and booking/code delivery cycle;
editing wording or the offset does not resend those channels. Changing an offset can make unsent
bookings immediately eligible. Creating a new trigger can send to existing eligible bookings that
have not received that trigger. In Settings → Messages, select the start-time trigger
for this behaviour. The existing API request is, for example:
```http
POST /api/automation/messages/triggers
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{"triggerKey":"when_start_time_has_passed","offsetMinutes":-60,"sendToBookingGuest":true,"sendEmail":true,"sendSms":false,"sendOnlyWhenRoomClean":false,"onlySendIfPaidOrCheckedIn":false,"title":"Your stay","emailMessage":"Your Guest Portal: {guestportal}"}
```
The same request fields are accepted by `PUT /api/automation/messages/triggers/{id}`.
HTTP `200` returns the configured trigger, including `offsetMinutes`, not proof of delivery.
Invalid configuration returns `400`; updating an unknown trigger returns `404`. After an uncertain
update, read `GET /api/automation/messages/triggers` before retrying with the intended settings.
No new event or queue payload is introduced. This sends guest communications and may disclose access details, so obtain explicit approval
for the audience and content. Do not retry an ambiguous create blindly: read
`GET /api/automation/messages/triggers` first. Verify actual channels in
`GET /api/automation/messages/logs/bookings/{bookingId}`; an eligible trigger, HTTP success, or block
reason `0` does not prove inbox delivery. Existing channel failure and deferred-delivery policies
continue to apply.
**Automatic source-provider checkout after expiry is opt-in.** The saved booking filter
`checkout_after_end` must have `variables.value` equal to JSON `true`. Its absence or `false` disables
expiry-driven checkout, including subsequent retries of expiry requests created before this policy.
No tenant receives this filter automatically. Guest Portal checkout and retries of an explicit guest
request do not require this filter. Local code expiry/removal rules remain independent.
Use the existing filter operations to configure it manually:
- `GET /integrations/booking-filters` reads the tenant's persisted list.
- `POST /integrations/booking-filters` replaces the entire list. Preserve every other intended
filter from the GET response; omitting one removes it. Do not submit a single-filter example over
an existing configuration without merging it.
These integration-service operations require a valid authenticated integration-service session
(session cookie or accepted bearer identity token) and the `Tenant` header. They do not accept a
tenant body/query argument. Do not assume the integration service accepts the machine-token scheme
of the main `/api` service. The example below uses the existing session-cookie flow and is a complete
body only for a tenant with no other filters:
```http
POST /integrations/booking-filters
Cookie: solvotix_session=<SESSION_COOKIE>
Tenant: <TENANT_ID>
Content-Type: application/json
[{"filterType":"checkout_after_end","variables":{"value":true}}]
```
The `200` response is the saved list. The new rule requires a JSON boolean; missing/non-boolean
`value` or missing tenant context returns `400`, and invalid/missing authentication returns `401`.
Saving a non-empty list also applies the existing filters to stored bookings. Enabling expiry
checkout is a high-impact change requiring explicit operator approval: the lifecycle monitor may
check out already expired, checked-in reservations in supported source providers on its next scan.
The filter does not invent check-ins and cannot enable checkout for an unsupported provider.
Removing the filter or setting `value:false` stops future expiry dispatch/retry attempts; it cannot
undo a request already sent to the provider. Read back the list after an uncertain save rather than
blindly replacing it again; a repeated save can reapply other filters to bookings.
Checkout uses the existing durable reconciliation state and lifecycle monitor, independently of
room-code availability. Transient failures are retried with backoff. Provider-specific reconciliation
may recognize an already completed checkout before retrying a mutation; do not assume exactly-once
external execution. Verify completion in the source provider. A local booking response or a
`data.state=checked_out` event alone is not provider confirmation. No new public retry endpoint is
introduced. Successful source synchronization reflects checked-out stays as checked out rather than
simultaneously checked in merely because a historical check-in timestamp exists.
#### Booking-code retention and recovery after checkout
Automatic checkout and expiry retain the booking's `roomCode` for recovery, regardless of
`code_picker_strategy` and whether a `room_code_deactivation_grace_days` filter is configured.
The lifecycle monitor clears the association on its next successful sweep at or after seven
24-hour days beyond the booking's stored end time, freeing the code for automatic reuse once
no other booking reserves it. Early checkout does not start this retention clock. Cleanup waits
while access remains eligible under an active grace policy, the room still contains the code,
or recorded lock slots show an active or pending credential. Missing end times, room records,
or unresolved assigned devices postpone cleanup. A concurrent change to the booking's room,
code, dates or check-in/out status prevents a stale sweep from clearing it. Successful cleanup
records a booking update; historical event snapshots are retained. A room change or explicit
code replacement can also change the assignment; deleting a booking removes its association.
Retention does not extend physical access. The lifecycle monitor still deactivates credentials
under the existing checkout, end-time and grace rules. Checked-out bookings trigger credential
removal even before their scheduled end. With the existing-code picker, the monitor removes
the old code from the room's inventory and locks and replenishes the available pool; a shortage
of confirmed uploaded pool codes does not leave the inactive credential uploaded or release it
for use by another booking. Other picker strategies also retain the booking association while
their existing credential removal/refresh operations run. Automatic generation excludes codes
still associated with any booking in the tenant, so a retained code is reserved for recovery.
After an accepted undo checkout or other eligible reactivation before the association is
cleared, the monitor uses the retained code and queues its upload instead of selecting a
replacement. Eligibility still follows the
existing end-time and optional grace policy: retaining a code does not authorize uploading it
for a booking whose access window has ended. Reconciliation skips recovery if the code belongs
to another room or its taken room entry belongs to another booking; it does not overwrite that
ownership. Room-code removal does not erase the association on later provider snapshots.
This is automatic lifecycle behavior; it adds no endpoint, request field, or retry operation.
Changing booking state to restore access remains a physical-access change requiring explicit
operator intent. Existing guest-checkout protection continues to govern whether an inbound
snapshot may reopen a booking. When a grace-days filter is present and the booking has no
`roomCode`, the monitor also restores an existing room reservation before orphan-pool cleanup:
the room must contain exactly one valid taken code owned by that booking. The booking must
still qualify under the shared access policy; `checkedIn` need not be true once its start has
passed, but `checkedOut=true` or an expired guest-reported departure still prevents recovery.
This works for all picker strategies, including disabled random generation, without generating
a replacement. Another room's code or another booking's reservation prevents recovery.
Ambiguous ownership is left unresolved; no PIN is inferred from historical events. Persistence
sets only the missing code and matches the original room, dates, check-in state and guest-report
flag, so a concurrent checkout, edit or replacement cannot be overwritten. Eligible owned
reservations are protected from orphan-pool rotation while recovery is unresolved.
This does not roll back a replacement code already assigned to a booking. When no unambiguous
room-owned reservation remains, eligible reactivation follows the normal code-selection policy.
A `roomCode` value on a checked-out or expired booking, or its event snapshot, is not evidence
of an active lock credential. After an eligible reactivation, verify the booking state and code,
then check `GET /api/bookings/rooms/{roomId}/codes/health` and credential lifecycle events for
the required locks. Queueing an upload or removal does not prove the device has applied it.
#### Recorded booking-code activity after checkout
`GET /api/bookings/bookings` returns the nullable, response-only `roomCodeActiveAfterCheckout`
field on each returned booking. This authenticated read requires bearer authentication, the
`Tenant` header and the existing receptionist role. It takes no new parameter or body:
```http
GET /api/bookings/bookings
Authorization: Bearer sat_<TOKEN>
Tenant: <TENANT_ID>
```
HTTP `200` returns the existing booking array. The field is `true` only after the stored `end`
has passed and an assigned code-capable room lock records that booking's PIN as added, or as
pending removal with a confirmed upload timestamp. Check-in/out state, grace configuration
and lock connectivity do not suppress this warning condition. If the booking's code association
is missing, the read can use taken room codes explicitly owned by that booking; it never picks
an available pool code. It is `false` before checkout time or when no presence is recorded for
the known code. A missing end cannot establish overdue status and returns `false`.
For overdue bookings, `null` (or an omitted value) means presence could not be determined,
such as a missing room, unresolved assigned device, no resolvable code-capable locks, an
unconfirmed upload, or a failed status read. Do not interpret that as confirmed removal.
For example, a returned booking with `checkedOut:true` and
`roomCodeActiveAfterCheckout:true` still has a recorded credential after its scheduled end.
This reflects stored device reports, not a live hardware probe or proof that a door opens.
Other booking operations do not compute this list-only field. It is not persisted and clients
cannot set it. Existing list filters still control which bookings are returned.
This is a low-risk, idempotent read requiring no separate operator approval. It queues no
device or provider action and emits no new event. Refresh this GET after a booking or device
state changes; use room-code health and credential lifecycle events for per-lock details.
Temporary read failures can be retried; existing missing/invalid authentication and role/tenant
restrictions still apply. The returned flag does not authorize credential reactivation or removal.
#### Automated-message room filtering
`POST /api/automation/messages/triggers` and `PUT /api/automation/messages/triggers/{id}`
accept `roomIds`, an optional list of room identifiers restricting automatic booking-guest delivery.
Authenticate with `Authorization: Bearer sat_<TOKEN>` and `Tenant: <TENANT_ID>`.
Use assigned room IDs, not display names or access codes:
```json
{"triggerKey":"when_start_time_has_passed","sendToBookingGuest":true,"sendEmail":true,"emailMessage":"Welcome!","roomIds":["room-101","room-102"]}
```
Only bookings whose `roomId` exactly matches an entry can send. IDs are trimmed and deduplicated
when saved. A nonempty list excludes bookings without a room assignment. Omitted or null
`roomIds` means all rooms on create and preserves the existing filter on update; `[]` clears it.
Nonempty lists require `sendToBookingGuest=true`; fixed recipients and template-only rules cannot
use them. Null or blank entries and incompatible recipient settings return HTTP 400.
`roomMessages` continues to control room-specific content independently of this sending filter.
The room check runs after trigger/time eligibility and before recipient-channel, cleaning and
payment checks or sending. Exclusion creates no successful log, event or deferred queue entry.
The processor re-evaluates current configuration and room assignments approximately every minute
and on booking import/update, within the existing trigger window. For start-passed triggers this
is booking start plus `offsetMinutes` until booking end or checkout. Delivery deduplication still
applies; changing the filter does not resend already delivered channels.
When an otherwise due booking is excluded and no automated message has succeeded,
`GET /api/bookings` exposes `automatedMessageBlockReason=5` (`ROOM_NOT_INCLUDED`). If another
eligible rule matches the room but is blocked, its concrete reason takes precedence: dirty room,
unpaid booking, missing recipient channel, then dispatch failure. Successful delivery by any
rule clears the warning regardless of rule order. Cleaning-warning previews only consider rules
that include the assigned room. No warning is introduced just because a trigger is not yet due.
Risk: guest communication policy with outbound sending effects; obtain approval for the intended
audience and content. Widening or clearing a filter can immediately send to eligible unsent bookings.
HTTP 200 returns saved configuration, not proof of delivery. Create is not idempotent: after an
ambiguous response, read `GET /api/automation/messages/triggers` before retrying. Verify the current
configuration before retrying an update. Verify `messageSent` and the block reason via
`GET /api/bookings`, and successful channels via
`GET /api/automation/messages/logs/bookings/{bookingId}`. Block reason 0 alone is not delivery proof.
#### 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`,
`4 = DISPATCH_FAILED`, and `5 = ROOM_NOT_INCLUDED`. 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 access during room-code grace
`GET /api/guest-portal/bookings/{bookingId}/validity?tenantId={tenantId}` reads the
booking's guest access window. It uses the existing booking-scoped guest workflow,
requires a booking belonging to that tenant, and has no bearer token or request body.
This is a low-risk, read-only operation needing no approval. It is idempotent and
safe to retry after transient failures; correct tenant/booking context before retrying
`404` for an unknown booking. Verify directly from the `200` response, for example:
```http
GET /api/guest-portal/bookings/<BOOKING_ID>/validity?tenantId=<TENANT_ID>
```
```json
{"status":1,"date":"2026-10-08T10:00:00Z","checkInTime":"2026-10-04T13:00:00Z","checkoutTime":"2026-10-05T10:00:00Z","checkedIn":true,"checkedOut":false}
```
`canCancelCheckout` is a read-only boolean, true only for a pending guest-reported checkout
whose stored deadline is strictly future and whose original end was saved. Normal booking expiry,
completed checkout, and legacy reports without rollback data return false. A client may offer
cancellation only while this flag is true and the remaining reported time is positive. The server
rechecks eligibility on cancellation, so stale availability does not authorize the write.
The example represents a three-day room-code grace period. `checkoutTime` remains
the stored scheduled departure. For status `1`, `date` is the effective access cutoff;
older backend versions returned null, so clients may fall back to `checkoutTime`.
For status `0`, `date` is the opening instant; for time-expired status `2`, it is
the effective access cutoff. Checked-out bookings or missing dates return status `2`
with `date:null`. Status `0` is upcoming and status `1` allows booking access.
When the tenant has `room_code_deactivation_grace_days`, the portal uses the same
shared policy as lock-code retention: access expires at stored booking end plus the
configured number of elapsed 24-hour days, and is denied at the cutoff itself.
`checkedOut=true` denies access immediately even inside grace, independently of
`showCheckoutButton`. With complete dates and an unexpired grace window, a checked-in
booking follows the lock policy even if its stored start is in the future; otherwise
access waits until start. Missing booking dates remain denied for guest access.
Reported departure always uses the stored five-minute end as the exclusive cutoff, even when
no grace filter exists. Otherwise, without the filter, the original inclusive start/end window
applies. A present zero-day filter uses the grace policy's exclusive end boundary. The last valid
nonnegative configured day value in creation order wins; malformed values do not
override earlier valid values and otherwise default to zero.
This policy governs guarded room/code reads, sensors, messages, offers, directions,
guest AI and checkout. Existing visibility toggles, room/device membership, activated
languages and module gates still apply. Sensor actions do not apply a second cutoff
at the original departure; they retain their existing physical-command and approval
rules. `403` means that access or another existing gate is denied; correct the state
rather than retrying automatically. The metadata and contact operations documented
below remain available outside the access window. `limitToValidTime` controls the
frontend's closed-window screen; disabling it does not bypass backend access guards.
The guest countdown stays tied to `checkoutTime`, not to the grace cutoff. Reporting departure
explicitly changes that stored time to the five-minute checkout deadline. Otherwise, after
scheduled departure the numeric countdown is replaced by the translated message
“Checkout time has passed” while access remains allowed. Negative countdown values are
not displayed. The portal checks validity again at the effective access
cutoff. Dates and departure-day checks keep using the authorized tenant timezone.
With grace, departure reporting remains available on and after the scheduled departure
day when the checkout button is enabled and the booking is still allowed.
The validity read creates no queue entry, event, settings change or physical command.
It reports policy eligibility, not proof that a code is currently working on hardware.
Queued or delayed lock removals do not extend portal eligibility. Physical open actions
still require explicit guest intent and must not be automatically retried after an
ambiguous response. Verify command delivery using the existing queue/event/device
workflow; an accepted command is not proof of physical completion.
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, room instructions are empty and the selected general template is still rendered.
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.
#### Language-specific general hotel messages
Use the existing authenticated `GET /api/guest-portal-admin/settings` and
`PUT /api/guest-portal-admin/settings`. Admin calls use the normal portal session or
`Authorization: Bearer sat_<TOKEN>` with the selected `Tenant` header. Tenant context
comes from the authentication filter; there is no new tenant argument or endpoint.
Existing role restrictions continue to apply. The portal configuration is under
**Guest portal → Portal behavior → General message template**; language-specific
editing requires an updated admin client using the new schema.
`generalMessageTemplate` remains the all-language fallback. Optional `generalMessages`
contains overrides with required `language` and `message` fields. Read current settings
and merge this example fragment into the complete object before PUT; preserve all
messages, offers, activated languages, toggles and other settings:
```json
{
"generalMessageTemplate": "Welcome! {roominstructions}",
"generalMessages": [
{"language": "de", "message": "Willkommen! {roominstructions}"},
{"language": "en", "message": "Welcome! {roominstructions}"}
]
}
```
Omitted or JSON `null` `generalMessages` preserves saved translations, including on
saves from older clients. `[]` explicitly clears all overrides. A nonempty array
replaces the entire list; omit one entry from that list to delete its translation.
New and legacy settings return an empty array from the admin GET when none exist.
Existing tenants continue using their stored fallback without manual migration.
Other settings keep their existing update semantics; this is not a general PATCH API.
Languages use the same normalization and supported-language catalogue as room messages:
trim, convert underscore to hyphen, extract the base language and lowercase it
(for example `DE_de` becomes `de`). Missing/unsupported language codes, null entries,
duplicate normalized languages and null/blank messages return `400` before settings
are written. Message text is trimmed. Remove an entry instead of submitting blank text.
Saving translations does not activate languages, and disabling a language does not
erase its translation. Configure `activatedLanguages` through the existing workflow.
For an activated requested language, general-template selection is matching override,
then `generalMessageTemplate`, with the existing built-in fallback for a null/blank
default. It does not arbitrarily fall back to another language. Independently select
room instructions by matching room and language, then room all-language default,
then empty text. Expand existing `{name}`, `{room}`, `{code}` and `{departureDate}`
variables in both pieces and insert room instructions only at `{roominstructions}`.
Without that placeholder the general template appears alone. Date-variable behavior
is unchanged. Automated email/SMS still receive raw room instructions through their
existing resolver; these general overrides are not inserted into outgoing messages.
Pre-stay instructions have separate plain-text translations; see the display-settings contract below.
PUT returns `200` with saved settings, confirming persistence only. Verify with admin
GET, then use the existing booking-scoped read for each activated language:
```http
GET /api/guest-portal/bookings/<BOOKING_ID>/messages?tenantId=<TENANT_ID>&language=de
```
This guest operation requires an eligible booking with a room and the requested language
activated, within the existing booking-window policy. No bearer token is required in
that workflow. It returns the existing `GuestPortalMessage` shape (`roomId`, normalized
requested `language`, composed `message`), not the translation list. The response language
remains the requested language even when fallback content is used. `400` means missing
input, `404` an unresolved booking, missing room assignment or unavailable language, and `403` a disallowed
booking window. Do not expose rendered messages or booking links in logs: templates may
contain access codes or guest data. Check the unchanged authentication policy for admin
`401`/`403` responses; never bypass it.
Risk is a reversible guest-visible content change. Obtain authorization for the tenant
and content before saving; authorization to implement the feature alone is not approval
to change a live hotel's text. No physical command, outbound guest communication or new
event is produced. Reads are repeatable. Repeating the same complete PUT is idempotent
for these fields, but after an ambiguous save read current settings before retrying to
avoid overwriting newer edits. Correct invalid input before retrying `400`.
#### Guest-portal buttons in automated messages
The existing automated-message templates and manual booking-message previews/sends support
`{guestportalbutton="Open guest portal", color="#1068F0", textColor="#F8F8F8"}`. Place this token in the body
where a button should appear, not inside an HTML attribute or an existing link.
`{guestportalbutton="Open guest portal"}` omits the custom background;
`{guestportalbutton}` also uses the default label `Open guest portal`.
The default background is Solvotix action blue `#1068F0` with off-white `#F8F8F8` text.
Custom `color` values accept `#RGB` or `#RRGGBB`; invalid values fall back to the default.
Light custom backgrounds use midnight `#001838` text by default. Optional `textColor` accepts
`#RGB` or `#RRGGBB` to override the text color; omitted or invalid values keep the automatic
text color. It can be used without `color`; when both are supplied, put `color` first.
For example: `{guestportalbutton="Open guest portal", textColor="#FFFFFF"}`.
Labels use double quotes, cannot
contain literal double quotes, and are HTML-escaped when rendered. Malformed tokens remain
unchanged. A booking ID and tenant context are required; without them tokens remain unchanged.
Email output contains an explicit clickable HTML link styled as a button, pointing to the
same booking URL as `{guestportal}`. General fallback messages, inserted room instructions,
and manual email overrides support the button. Plain-text email line breaks are preserved.
The button token expands only in email bodies. SMS and subjects leave it unchanged; use
`{guestportal}` for a plain URL there. When using a general fallback containing a button,
configure a separate SMS body with `{guestportal}`. Multiple email buttons are supported. The existing `{guestportal}` variable continues to produce a plain URL.
Configure through `POST /api/automation/messages/triggers` or
`PUT /api/automation/messages/triggers/{id}`, using `Authorization: Bearer sat_<TOKEN>`
and `Tenant: <TENANT_ID>`. Tenant context is resolved by authentication. Example create body:
```json
{
"triggerKey": "on_day_of_arrival",
"sendToBookingGuest": true,
"sendEmail": true,
"title": "Your guest portal",
"emailMessage": "Hello {name},\n\n{guestportalbutton=\"Open guest portal\", color=\"#1068F0\", textColor=\"#F8F8F8\"}"
}
```
The existing create/update contract applies: `200` returns the stored unexpanded template,
`400` indicates an invalid request, and updating an unknown trigger returns `404`.
This is a sensitive guest-access link and an external-communication configuration change:
obtain approval for recipients/content before saving or sending. Do not publish booking URLs.
Create is not idempotent; verify via `GET /api/automation/messages/triggers` before retrying an
uncertain write. Preserve unrelated fields on update.
Verify without sending using `POST /api/bookings/bookings/{bookingId}/message-preview`
with `{"triggerId":"<TRIGGER_ID>"}` or an `emailMessage` override. This read-only operation
is repeatable and requires receptionist access and the same authentication/tenant context.
Inspect the `200` preview status, rendered email link target and the separately configured SMS/subject content;
missing trigger/recipient appears in preview status, and an unknown booking returns `404`.
Sending via `POST /api/bookings/bookings/{bookingId}/send-message` requires recipient/content
approval and receptionist access. `202` indicates dispatch success, not inbox receipt;
missing trigger/contact details yield `400`, unknown booking `404`. Do not blindly retry
uncertain or partial sends. Verify application processing through
`GET /api/automation/messages/logs/bookings/{bookingId}`. Successfully delivered button links
qualify as guest-portal code content under the existing code-publication policy when the
booking has a current code. Buttons introduce no new event, queue, endpoint or idempotency key,
and do not change guest-portal authorization or booking-validity rules.
#### Automated-message date variables
Configure templates through `POST /api/automation/messages/triggers` or
`PUT /api/automation/messages/triggers/{id}`. Authenticate with
`Authorization: Bearer sat_<TOKEN>` and `Tenant: <TENANT_ID>`; tenant context is
resolved by the authentication filter, not a request-body tenant argument.
`{arrivalDate}` and `{arrivalDateText}` use the booking’s `start` instant, with the same
tenant-timezone conversion, numeric format and English text format as the departure variables.
Use `{arrivalDateText}` for arrival and `{departureDateText}` for departure. All four variables
work in subjects, message bodies, inserted room instructions and manual booking overrides.
A missing start date blanks only the arrival variables; a missing end date blanks only the
departure variables. A missing booking blanks all four.
`{departureDate}` formats the booking's `end` instant after converting it to the tenant's
configured timezone. The timezone country selects the regional numeric convention, with
two-digit days/months and a four-digit year: `Europe/Oslo` gives `18.09.2026`,
`America/New_York` gives `09/18/2026`, and `Europe/London` gives `18/09/2026`.
`{departureDateText}` formats the same local date as `18 sep 2026`: two-digit day,
lowercase English abbreviated month, and four-digit year. Guest/template language does
not change either date format. The two variables work in `title`, `message`,
`emailMessage`, `smsMessage`, inserted room instructions, and manual message overrides
(`subject` is the manual subject field).
Missing booking or end timestamps produce an empty string for either departure variable.
Missing/invalid tenant timezones resolve to UTC. UTC, fixed offsets, unmapped timezone
countries, and countries without a configured regional convention use `yyyy-MM-dd` for
`{departureDate}`; the text variable keeps its English format. Both renderers use the
same local calendar date, including daylight-saving rules. Existing templates using
`{departureDate}` now receive regional formatting instead of the previous UTC ISO date.
This applies to automated booking messages and manual booking-message sends/previews;
it does not change the separate guest-portal date variables.
Example create request (requires a valid supported trigger and a booking with an end date
for date substitution):
```json
{
"triggerKey": "on_day_of_arrival",
"sendToBookingGuest": true,
"sendEmail": true,
"title": "Your stay: {arrivalDateText} – {departureDateText}",
"emailMessage": "Hello {name},\n\nYour departure date is {departureDate}.\n({departureDateText})"
}
```
A successful create/update returns `200` with the stored trigger, whose template variables
remain unexpanded until rendering. Invalid trigger requests return `400`; updating an
unknown trigger returns `404`. These writes change future guest communications: obtain
approval for the recipient/content change before applying it. Create is not idempotent;
do not blindly retry after an uncertain result. Read `GET /api/automation/messages/triggers`
to verify the saved configuration. For updates, preserve unrelated fields and re-read
before retrying to avoid overwriting another user's changes.
Use `POST /api/bookings/bookings/{bookingId}/message-preview` with a body such as
`{"triggerId":"<TRIGGER_ID>"}` to verify the rendered dates without sending. This is a
read-only operation, safe to repeat and needs no send approval; it requires receptionist
access and the same bearer/tenant context. Its `200` response contains the preview status,
rendered email/SMS and subject when available; inspect `status` for missing trigger or
recipient, and handle `404` for an unknown booking.
`POST /api/bookings/bookings/{bookingId}/send-message` uses the same date formatting and
request fields. Sending is an external communication and requires approval for the
recipient/content. It requires receptionist access; `202` indicates dispatch returned
success, not proof of inbox delivery. Missing trigger/contact details yield `400`, and
an unknown booking yields `404`. Do not blindly retry a send after a timeout or partial
failure because duplicate messages are possible. Verify channel/content records using
`GET /api/automation/messages/logs/bookings/{bookingId}` (receptionist access); these
records confirm application delivery processing, not receipt by the guest. Date expansion
does not introduce a new event, queue, or idempotency key.
Guest-facing tenant branding uses `GET /api/guest-portal/bookings/{bookingId}/tenantName`.
Use the booking ID from the guest portal link and supply `Tenant: <TENANT_ID>`; no bearer token
is required. The authentication filter resolves tenant context globally and also accepts the
legacy `tenantId` query parameter when the header is absent. The header takes precedence.
The booking must exist in that tenant. This read is available before, during and after the stay,
including after checkout, independently of guest display settings.
```http
GET /api/guest-portal/bookings/<BOOKING_ID>/tenantName
Tenant: <TENANT_ID>
```
A `200` response contains only the stored display name, for example
`{"tenantName":"Example Hotel"}`. Tenant configuration and credentials are excluded.
Missing tenant context returns `400`; an unknown booking, unknown tenant, or missing/blank
name returns `404`, with no response body. This is a low-risk, read-only operation requiring
no approval. It is idempotent and safe to retry after a transient failure; correct the context
or booking before retrying `400`/`404`. Verify branding directly from the `200` response.
No request body, queue entry, physical operation or event is involved.
Guest-facing tenant timezone uses `GET /api/guest-portal/bookings/{bookingId}/timezone`.
Like the tenant-name call, it requires an existing booking in the current tenant and no bearer
token. Supply the `Tenant` header; the authentication filter accepts the legacy `tenantId` query
parameter only when that header is absent. Tenant context is resolved globally. The call remains
available before, during and after the stay, including after checkout, independently of display settings.
```http
GET /api/guest-portal/bookings/<BOOKING_ID>/timezone
Tenant: <TENANT_ID>
```
A `200` response contains only the configured timezone, for example
`{"timezone":"Europe/Copenhagen"}`. This is the stored value, without normalization or a UTC fallback.
Missing tenant context returns `400`; an unknown booking, unknown tenant, or missing/blank timezone
returns `404`, with no response body. This is a low-risk, read-only operation requiring no approval.
It is idempotent and safe to retry after transient failures; correct context or booking before
retrying `400`/`404`. Verify the timezone directly from the `200` response. No request body, queue
entry, physical operation or event is involved.
Guest-facing display settings come from
`GET /api/guest-portal/bookings/{bookingId}/display-settings?tenantId={tenantId}`. No bearer token
or request body is required. The booking must belong to the selected tenant. This read remains
available before, during and after a stay, including checkout.
```http
GET /api/guest-portal/bookings/<BOOKING_ID>/display-settings?tenantId=<TENANT_ID>
```
Example `200` response for an upcoming stay with both new features enabled and contact details not yet confirmed:
```json
{"showRoom":true,"showCode":true,"showOpenButton":true,"showCheckoutButton":true,"limitToValidTime":true,"askForContactDetailsConfirmation":true,"showPreStayInstructions":true,"preStayInstructions":"Check-in opens at 15:00. Please contact reception if you arrive early.","contactDetailsConfirmed":false}
```
`askForContactDetailsConfirmation` is true only when the tenant enables it and `contactDetailsConfirmed` is false,
even when the booking already has both phone and email.
It tells the client whether to prompt for review and confirmation of both contact details; this read does not save or confirm them.
`contactDetailsConfirmed` reports whether the guest explicitly confirmed both phone and email.
`showPreStayInstructions` is the configured tenant toggle. `preStayInstructions` contains text only
when that toggle and `limitToValidTime` are true, the booking has not started or checked out,
start and end exist with end after start, and the configured text is nonblank. Otherwise it is
null. Render it as plain text, without interpreting HTML or expanding template variables.
The text uses the independently selected pre-stay translation or its default; it never uses the room-message template machinery.
These settings do not grant early access to room codes, regular messages or physical operations.
Optional `language` selects pre-stay instructions in an activated guest language:
```http
GET /api/guest-portal/bookings/<BOOKING_ID>/display-settings?tenantId=<TENANT_ID>&language=de
```
The response shape is unchanged: `preStayInstructions` holds only the selected plain text,
not the translation list. A normalized active language selects its `preStayMessages` entry;
otherwise the existing `preStayInstructions` default is used. Omitted, blank, unsupported,
inactive or untranslated languages all use the default. A blank default with no matching
override yields null. Existing clients may omit `language`. All pre-stay visibility conditions
above still apply, even when a translation exists. HTML and variables such as `{code}` and
`{roominstructions}` remain literal text; clients must not render HTML or expand variables.
Language selection never grants booking access and introduces no settings writes.
Configure pre-stay translations through the same authenticated admin settings GET/PUT,
using the existing session or bearer authentication and `Tenant` header. There is no new
tenant argument. Read and preserve the complete settings object, then merge for example:
```json
{
"showPreStayInstructions": true,
"limitToValidTime": true,
"preStayInstructions": "Check-in opens at 15:00.",
"preStayMessages": [
{"language": "de", "message": "Die Anreise ist ab 15:00 Uhr möglich."},
{"language": "en", "message": "Check-in opens at 15:00."}
]
}
```
This is a fragment, not a complete replacement. `preStayMessages` follows the general-message
translation rules: omitted/null preserves existing translations, `[]` clears all, and a nonempty
array replaces the list. Removing one entry deletes that override. Required language codes are
normalized using the same supported catalogue as room messages (`DE_de` becomes `de`). Null entries,
missing/unsupported languages, duplicate normalized languages and null/blank messages return `400`
before settings writes. Message text is trimmed. Saving does not activate a language, and disabling
a language retains its translation. New and legacy admin settings return an empty list when none
exist; the default text continues to work without manual migration. The default field retains its
existing save behavior: omitted/null clears it. Other settings must be retained in the PUT body.
This is a reversible guest-visible content change requiring authorization for the selected tenant
and text. Admin success is `200` with persisted settings, not evidence a guest viewed them. No new
event, outbound communication or device command is produced. Verify the admin GET, then read the
booking display settings for each language using an eligible future booking, and verify that text
is absent outside the permitted pre-stay conditions. Do not log booking identifiers or private
instructions. Reads are repeatable; identical complete saves are idempotent for these fields, but
read current settings before retrying an ambiguous write to avoid overwriting newer edits. Correct
invalid input before retrying `400`; respect existing authentication/role failures (`401`/`403`).
The optional guest language does not introduce an error for an unavailable translation.
The endpoint is low-risk, read-only, idempotent, safe to retry after transient errors, and requires
no approval. Verify directly from its `200` response (`Cache-Control: no-store`); no queue or event
is produced. An unresolved booking returns `404`; correct tenant/booking context before retrying.
On failure hide optional content and contact confirmation prompts, treat `show*` as false and `limitToValidTime`
as true. Guest clients must never call the authenticated admin settings endpoint.
Configure these fields through the existing authenticated tenant settings workflow:
`GET /api/guest-portal-admin/settings`, then `PUT /api/guest-portal-admin/settings` with the complete
settings object and `Tenant` header. Retain existing fields, messages, offers and languages.
Both `askForContactDetailsConfirmation` and `showPreStayInstructions` default to false; `preStayInstructions`
defaults to an empty string. For example, merge the following fields into the fetched object:
```json
{"askForContactDetailsConfirmation":true,"showPreStayInstructions":true,"preStayInstructions":"Check-in opens at 15:00.","limitToValidTime":true}
```
The example is a fragment, not a complete replacement payload. Omitted new toggles reset to false;
null instruction text clears it and saved text is trimmed. Obtain tenant approval for changes to
guest-visible text and contact confirmation policy. Success returns `200` with saved settings; a null
payload returns `400`. Verify with the admin GET and a booking display-settings read. Reapplying the
same new fields is safe, but read current settings before retrying to avoid overwriting concurrent
edits. These fields do not trigger events, queue entries or physical operations.
Guests can fill a missing phone number with
`PUT /api/guest-portal/bookings/{bookingId}/phone`. Use the booking ID from the guest portal link
and the `Tenant` header. No bearer token is required; the filter resolves tenant context globally
and accepts the legacy `tenantId` query parameter only when the header is absent.
```http
PUT /api/guest-portal/bookings/<BOOKING_ID>/phone
Tenant: <TENANT_ID>
Content-Type: application/json
{"phone":"+4512345678"}
```
The tenant must enable `askForContactDetailsConfirmation`. Phone-only submission is available before,
during and after the stay, including checkout, independently of `limitToValidTime`. The input accepts
7–15 digits and an optional leading `+`. Spaces, parentheses, dots and hyphens are removed; the raw
input is limited to 64 characters. A country code is recommended; no country code is inferred and
phone ownership is not verified. This phone-only compatibility endpoint fills a missing phone but
does not confirm contact details or protect new submissions from later provider syncs. It cannot
change already-confirmed contact details. Use the contact-details workflow below for confirmation.
`204 No Content` confirms the number is stored locally, or that the same normalized number already
exists. An existing different number returns `409` without replacement. Missing tenant context,
missing/malformed body, or an invalid phone returns `400`; disabled contact confirmation prompt returns `403`; an
unknown booking returns `404`. A concurrent conflicting change also returns `409`. These responses
never disclose an existing phone number. Saving only the phone does not clear
`askForContactDetailsConfirmation`: the prompt remains active until both contacts are confirmed
or the tenant disables the prompt. Use the contact-details workflow to complete confirmation.
This is a moderate-risk personal-data mutation requiring explicit guest submission of their number.
Retry the same payload after a transient error; identical normalized submissions do not write again
or emit duplicate events. On `409`, stop and refresh the portal rather than attempt replacement.
Correct input/context before retrying `400`/`404`; respect `403`. Success logs the existing booking
update event `action=1034`, `data.state=updated`, with guest actor and normal booking metadata,
including the phone and any existing contact, room, code and date fields. Treat event content as
sensitive. Event logging is separate from persistence: a failure can occur after the number was
saved, and a retry may return `204` without recreating the event. Use the HTTP response for local
save confirmation; neither it nor the event proves phone ownership, message delivery or provider
synchronization. This endpoint does not enqueue a message, physical command or provider update.
Existing automation may subsequently use the booking's stored phone under its normal rules.
To review and confirm both contact details, use the booking-scoped guest workflow:
```http
GET /api/guest-portal/bookings/<BOOKING_ID>/contact-details
Tenant: <TENANT_ID>
```
No bearer token or request body is required. The authentication filter resolves tenant context
from the header, with legacy `tenantId` query fallback only when the header is absent. A `200`
response contains the stored phone, email and confirmation state, for example:
```json
{"phone":"+4512345678","email":"guest@example.com","contactDetailsConfirmed":false}
```
Phone and email may be null before confirmation. This read exposes personal contact data for
guest review: do not log it or cache it (`Cache-Control: no-store`). It is read-only, idempotent,
requires no approval and is safe to retry after transient failures. Missing tenant returns `400`;
an unknown booking returns `404`. Verify directly from the response; no event or queue entry is produced.
After the guest reviews and confirms both values, submit:
```http
PUT /api/guest-portal/bookings/<BOOKING_ID>/contact-details
Tenant: <TENANT_ID>
Content-Type: application/json
{"phone":"+4512345678","email":"guest@example.com","contactDetailsConfirmed":true}
```
Both contacts are required. Phone validation is the same as the phone-only endpoint (7–15 digits,
optional leading `+`, formatting removed, at most 64 raw input characters). Email must be a single
valid address, at most 254 raw characters; outer whitespace is trimmed, letter case is retained,
and line breaks and display-name/list syntax are rejected. Confirmation must explicitly be `true`;
missing/false confirmation is rejected. Confirmation records the guest's review; it does not verify
ownership through a code or email challenge. Both GET and PUT remain available before, during and
after the stay, including checkout, independently of `askForContactDetailsConfirmation` and `limitToValidTime`.
The configuration toggle controls the optional contact confirmation prompt, not the ability to
review or confirm contacts. The guest display value becomes false after successful confirmation.
An unconfirmed booking's phone and email can be corrected by this submission. The two values and
`contactDetailsConfirmed=true` are written together, conditional on the stored contact snapshot.
Later provider syncs preserve both confirmed values, including against blank or different provider
contacts. Other booking fields continue to sync. Existing legacy phone-only origin markers continue
to protect only the phone; they do not confirm the email. There is no automatic backfill of contact
confirmation. A whole-booking sync save that loaded the booking before confirmation can still
overwrite a simultaneous confirmation; this change does not add concurrency protection to those saves.
`204 No Content` means contacts were saved and confirmed or the same normalized phone and identical
trimmed email were already confirmed. Different already-confirmed details or concurrent conflicts
return `409` without changing them. Missing tenant, invalid/missing contacts or absent/false confirmation
return `400`; unknown booking returns `404`. Confirmation cannot be cleared or edited through this endpoint.
This is a moderate-risk personal-data mutation requiring explicit guest confirmation of both values.
Agents must not auto-confirm them merely because the booking already has contact details. Retry the same
payload after transient failure; on `409`, re-read and let the guest review rather than silently replacing
the values. Correct input/context before retrying `400`/`404`. Verify with GET contact-details, checking
both stored values and `contactDetailsConfirmed=true`; display-settings also exposes the flag.
A write logs existing event `1034` with `data.state=updated`, guest actor and normal booking metadata,
including phone and email. No-op retries emit no event. Logging is separate from the atomic contact write
and can fail after persistence. No message, provider update or physical command is queued; existing
automation may use the stored contact details under its normal rules. Confirmation is not evidence of
provider synchronization, contact ownership or message delivery.
Guest departure reporting uses `POST /api/guest-portal/bookings/{bookingId}/checkout?tenantId={tenantId}`
with the booking ID from the guest link and no request body or bearer token. It requires a known
booking with a room, an enabled checkout button, and a currently valid guest-portal access window.
Navigate to the Guest Portal and select **Report departure**, then explicitly confirm departure.
A `200` persists `checkoutSyncRequestedByGuest=true` for every booking source, sets `end` and response
`checkoutTime` to five minutes after the request. The booking document atomically stores the original
`end` as internal `guestCheckoutPreviousEnd`, plus the previous checkout request timestamp and
previously-checked-in request marker. These hidden, sync-protected rollback fields survive restarts
and are removed after cancellation or checkout completion. `checkedOut` remains false and
`checkedIn` and the room's cleaning state are unchanged. The response confirms a scheduled departure report,
not completed local checkout, provider checkout, or physical credential removal.
While this report is pending, portal and lock-code eligibility end at the stored five-minute deadline;
the room-code grace-days filter adds no time to a reported checkout. Normal expiry without an
explicit report does not cause the monitor to set `checkedOut=true`. The lifecycle monitor (default
fixed delay: 60 seconds) completes local checkout at its first pass at or after the deadline, only
when `checkoutSyncRequestedByGuest=true` and the booking is still not checked out. Completion is a
conditional field update matching the saved deadline: it sets `checkedOut=true` and `checkedIn=false`.
A concurrent deadline change or accepted cancellation prevents that stale update. Completion can
therefore lag the visible countdown by a monitor interval; physical removal remains subject to
queue/delivery timing. Room-dirty marking is deferred until local completion. A persisted cleaning
pending flag lets the monitor retry failed cleaning updates independently of code/provider handling.
For supported providers, the existing durable reconciliation then requests provider checkout only
if the guest's booking was checked in when departure was reported. It does not dispatch during the
five-minute countdown, and explicit guest requests do not require the opt-in `checkout_after_end`
filter. Transient provider failures are retried. Pending and completed reports retain their local
status and reported deadline during subsequent inbound snapshots; code-use events do not initiate
a new provider check-in. The departure report logs existing booking event `1034` with
`data.state=updated`; monitor completion logs `data.state=checked_out`. Event writes are separate
from booking persistence, so a missing event does not prove that the report or transition failed.
Verify scheduled acceptance from response `checkedOut=false` and `checkoutTime`. Re-read the
booking-scoped validity operation for access cutoff and `checkedOut` after the deadline, and verify
source-provider completion in that provider. Local status, events, or physical removal alone do not
prove provider completion. The report response's `roomMarkedDirty` is false because cleaning is deferred until completion.
Treat reporting departure as a high-impact state change requiring explicit guest intent. Do not
repeat after success or automatically retry an ambiguous response; read current booking state
first. Pending and completed repeat reports return `403` without extending the deadline. After staff
explicitly reopen a checked-out booking, the previous departure cycle is cleared and the guest can
report again if access is valid and checkout is enabled; the new report receives a fresh five-minute
deadline. Other expected failures are `403` for a disabled button, missing room assignment, invalid access window,
or concurrent booking change, and `404` for an unknown booking or room.
#### Cancel a pending guest departure report
```http
POST /api/guest-portal/bookings/{bookingId}/checkout/cancel?tenantId={tenantId}
```
Operation ID: `cancelCheckout`. Use the same booking ID and tenant query context as departure
reporting. No bearer token or request body is required. This is a high-impact booking/access mutation
requiring explicit guest intent. A cancellation must be submitted before the reported five-minute
end; it cannot undo a completed local or provider checkout. It remains available if the checkout
button visibility setting changes after the report was accepted.
The conditional booking update requires `checkoutSyncRequestedByGuest=true`, `checkedOut=false`,
a strictly future reported end, and a saved original end. It matches the document ID, report end
and saved original end so a competing monitor completion or date edit prevents stale restoration.
`200` confirms local persistence and returns `BookingValidityResponse` for the restored booking:
`checkoutTime` is the exact saved original end and `canCancelCheckout=false`. The operation clears
the guest report flag, restores the previous checkout request timestamp and previously-checked-in
request marker, and removes the rollback fields. `checkedIn` and `checkedOut` are unchanged. It
leaves cleaning state unchanged and queues no provider or physical action. Normal expiry, grace,
automation and provider policies continue to apply to the restored booking; an original end that
is already past does not guarantee reopened portal access.
Existing event `1034` with `data.state=updated` records the cancellation with the guest actor.
Event logging is separate from persistence. Re-read booking validity to verify the restored
`checkoutTime` and `canCancelCheckout=false`; do not infer physical lock state from HTTP 200.
The conditional cancellation write does not serialize a whole-booking provider save already in
progress, which can still overwrite concurrent changes; verify current booking state after an
ambiguous result rather than assuming the cancellation took effect.
Errors: `400` for missing or blank tenant context; `404` for unknown booking; `409` for no pending
report, deadline reached, completed checkout, missing legacy rollback data, or a competing booking
change. Reports created before rollback storage was introduced cannot be cancelled through this
operation: their original end is not inferred from timestamps or provider data. Repeated cancellation
returns `409`. Do not automatically retry an ambiguous mutation. Verify current booking validity
first, and require renewed explicit guest intent for any further mutation.
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.
### 18.7 Provider configuration and time-window previews
Some integration-process adapters expose separate configuration, discovery, preview and apply
operations. Discover the adapter's exact operations in the **integration-process** OpenAPI
contract; do not infer that another provider supports the same suffixes. The following catalog
entry uses the policy below:
| Base path | Configuration | Discovery | Preview | Apply | Disconnect |
|---|---|---|---|---|---|
| `/integrations/lodgify` | `GET /settings`, `POST /settings` | `GET /properties`, `GET /rooms` | `POST /sync/test` | `POST /sync` | `DELETE /auth` |
All paths in the row are relative to its base path. Call the integrations-process deployment,
not the runner base URL. Authentication on this service is an existing Firebase ID bearer token
or `solvotix_session` cookie plus the global `Tenant` header. The controller verifies the user's
membership in that tenant. Runner `sat_` tokens are not accepted by this service's current filter;
an autonomous machine integration must not substitute its runner token or claim this frontend
configuration surface is machine-token-enabled. No tenant ID belongs in these bodies or query parameters.
Configuration is security-sensitive. Use the operator's authorized provider credential and
confirm the intended scope. Read configuration first; the response contains only `configured`,
`enabled`, and `propertyId`, never the key. A missing record returns false flags and null scope.
To save without enabling background activity:
```http
POST /integrations/lodgify/settings
Authorization: Bearer <FIREBASE_ID_TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{"apiKey":"<PROVIDER_API_KEY>","propertyId":12345,"enabled":false}
```
This example returns `200` with `{"configured":true,"enabled":false,"propertyId":12345}`.
A first save requires `apiKey`; later omission retains it, while a supplied blank key is invalid.
Null/omitted `propertyId` selects all properties accessible to that credential; include it when
saving again to preserve a restricted scope. Omitted `enabled` defaults to true on first save
and otherwise retains its value. Saving does not verify the key at the provider. Repeated saves
update the same record; read back after an uncertain result to avoid overwriting a newer change.
Discovery and preview are read-only and need no physical-operation approval. Property discovery
returns all accessible properties; room discovery returns `{rooms, unsupportedRoomTypes}` for
the saved scope. Discovery does not persist rooms. Both work while imports are disabled.
Room IDs returned by discovery are the IDs used by the existing room/device configuration APIs.
Do not choose a physical unit on behalf of a provider when its assignment is ambiguous.
```http
POST /integrations/lodgify/sync/test
Authorization: Bearer <FIREBASE_ID_TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{"start":"2026-10-01T00:00:00Z","end":"2026-10-05T00:00:00Z"}
```
Both timestamps require a UTC offset or `Z`, and start must precede end. Stay overlap uses
`arrival < end && departure > start`; existing local stay dates also select moved/cancelled
reservations for reconciliation. The `200` response echoes `start`, `end`, `dryRun=true`, and
`scanned`, `matched`, `bookings`, `deletions`, `skipped`, `categories`, `rooms`,
`unsupportedRoomTypes`, and `warnings`. Counts describe records that would be processed, not new
record counts or proof of physical state. Warnings contain provider booking IDs and reasons,
limited to 100 entries. Invalid records whose date window cannot be determined can also be
reported as skipped. Review skips and ambiguous assignments before applying.
Preview makes no configuration, inventory, booking, log, message or device writes. It does not
simulate existing booking filters or automation. Date-only/local provider values use the tenant
timezone (existing resolver fallback UTC); offset-bearing values preserve their instant.
Enabling imports permits background processing. Live apply accepts the same body at `POST /sync`
and requires enabled settings. Preview and apply accept reservations regardless of booking
status, including provisional or declined statuses; status changes alone never remove records.
Live apply imports inventory and reservations through the existing booking-upsert flow. Every
imported status can trigger the same booking filters, access-code and message automation.
Explicit trash (`is_deleted=true`) or cancellation (non-null `canceled_at`) prevents import and
removes previously imported records in scope. Apply returns `200` with `dryRun=false` only after
processing. One reservation produces at most one booking; room splitting is unsupported.
Ambiguous/missing rooms are skipped; their existing records remain unchanged and require
operator review. Missing list entries alone do not justify deletion. Time fields and booking
status alone do not establish confirmed occupancy.
Risk: live apply and enabling background imports can change booking access and trigger existing
code/message automation. Require operator approval for those effects. Provider reads finish
before apply begins, but apply is not transactional and may fail after partial changes. Stable
provider-prefixed IDs make record upserts repeatable; side effects are not guaranteed exactly
once. Do not automatically retry an ambiguous live apply. Inspect existing booking/room views,
`GET /integrations/logs` (for the cataloged entry, `search=lodgify`), message-delivery records and
relevant device events/queues first. A sync result or log confirms processing only, not delivery
or physical completion. Read-only preview/discovery may be retried with backoff.
Background delivery supports polling (by default every 600000 ms after each run, with a
tenant-local window from yesterday through the end of the third following day), explicit sync
and configured provider callbacks. Provider booking write-back is not performed. Explicit windows
may scan all provider booking pages; allow for long requests. No new runner event types are added.
`DELETE /auth` disables future imports and callbacks, removes owned provider subscriptions,
and then clears the saved key, returning `204`. Cleanup failure keeps the key and
subscription records for retry; callbacks and future imports remain disabled. It is repeatable and requires authorization to disconnect. It does not revoke the provider key, cancel an in-flight
run, remove imported data, or stop shared automation for existing bookings. Verify
`configured=false` using `GET /settings`; separately authorize any booking/code cleanup.
Errors: `400` for missing tenant/settings/key, malformed input, invalid property/timestamps or
disabled live apply; `401` for failed authentication; `403` for failed tenant membership; `409`
for key replacement before webhook cleanup, callback configuration conflicts or no failed work
to retry; `429`
for a provider rate limit; `502` for rejected credentials/provider requests, malformed responses
or incomplete/nonadvancing/excessive pagination; `503` for network unavailability/interruption
or missing/invalid public HTTPS callback configuration.
Database/automation failures may return `500` after partial apply. Correct configuration/input
errors first, wait before retrying reads after rate limits, and follow state verification before
retrying live operations. Credentials and raw provider error payloads are not returned or logged
by the adapter. Never expose saved credentials in a frontend response or agent transcript.
### 18.8 Saved provider subscriptions and asynchronous reconciliation
Apply this policy only to the cataloged operations; do not assume other adapters share it.
Management suffixes are relative to the base path. Callbacks are absolute provider-only paths.
| Base path | Read setup | Configure | Remove | Retry failed work | Callback |
|---|---|---|---|---|---|
| `/integrations/lodgify` | `GET /webhooks` | `POST /webhooks` | `DELETE /webhooks` | `POST /webhooks/retry` | `POST /webhooks/lodgify/{token}` |
Management uses the integrations-process Firebase bearer/session and global `Tenant` context
with membership validation described above. Do not supply tenant arguments or use runner `sat_`
tokens. GET is read-only (200, safe to retry). Configuration/removal are security-sensitive;
obtain authorization for the intended subscription scope. Enabling callbacks together with
enabled import settings permits booking/access/message automation, requiring operator approval.
Retry also requires approval after reviewing possible partial effects.
Before registering, an administrator must route the public HTTPS callback path to the
integrations-process service and set the adapter's callback-base property from its OpenAPI
configuration description (or retain its documented default). The cataloged callback base defaults
to the existing public webhook host; the exact URL is in the manifest catalog. The host is
configured on the server, not accepted from a frontend
request. Redact callback capabilities from proxy/access logs. Save a valid provider key and
property scope first. Existing timestamp-based preview is the read-only verification mechanism.
For the cataloged configuration operation, send the following body with `Content-Type:
application/json` and the management authentication above:
```json
{"enabled":true}
```
`enabled` is required. Omit `events` for all supported booking events, or pass a nonempty subset
of the GET response's `supportedEvents`; unknown/null entries are rejected. Refer to the generated
operation documentation for exact event names. Each event gets its own unique callback URL.
Registration saves the provider subscription ID and once-only creation secret. POST returns 200
with saved state; this is not itself a booking synchronization. Setup can partially succeed.
After a timeout/failure, read status and repeat the same configuration: registration reconciles
exact saved URL/event pairs, including prior requests whose responses were lost. It never removes
another application's subscriptions. Concurrent configuration across replicas is not serialized.
GET/POST return `enabled`, selected `events`, `supportedEvents`, `subscriptions` and `work`.
Subscription entries contain `event`, `subscriptionId`, `enabled`, `registered`, `cleanupRequired`,
and `secretStored`. No API key, provider secret, callback token or callback URL is returned.
Registration flags are saved state, not a provider health check. `cleanupRequired` marks a
subscription needing reconciliation at disconnect. `secretStored=false` can follow a lost
creation response because that secret cannot be recovered through the provider list operation.
Callback authentication uses a random 256-bit URL capability stored in the subscription. There
is no bearer/session/Tenant requirement and no provider HMAC signature verification. The body is
ignored; it has no required schema or event-input keys. Tenant/event routing comes exclusively
from the saved subscription. The receiver uses callbacks only as notifications and obtains
booking data from the provider API with the saved key. Protect the URL as a credential. Callback
202 means the notification was queued with majority/journal-acknowledged database writes, not
that booking changes were applied; invalid or disabled capabilities return 404 and persistence
failure returns 500. Do not use the callback as a frontend test-sync endpoint.
A dedicated worker checks pending notifications every second when idle, coalesces notifications
before processing, and preserves another pass for notifications arriving during processing.
It reconciles the saved property scope across all stay dates, including explicit cancellations,
through the existing importer with no room splitting. It does not inherit the frontend's manual
time window. Large inventories, provider throttling or another active import can delay completion.
Polling remains a fallback. Disabled import settings consume notifications with outcome `disabled`;
re-enable imports and perform an authorized manual sync to backfill missed changes.
`work` contains `pending`, `processing`, `reviewRequired`, `lastReceivedAt`, `lastProcessedAt`,
`lastOutcome` and sanitized `lastError`. Outcomes are `synced`, `disabled`, `review_required`,
or initially null. `lastProcessedAt` records the last successfully consumed batch. On processing
failure or expired worker lease, replay is blocked for review; additional callbacks stay queued.
Inspect booking/room state, integration summaries, message delivery and device events/queues
before POSTing to the retry operation (no body). Retry requires enabled setup and imports, returns
202, and coalesces repeated requests while pending. No failed/pending work returns 409. Verify
completion through GET, then inspect downstream effects separately. Webhook leases coordinate
workers across instances; manual/polling imports are only serialized within a process. Stable
record IDs do not guarantee exactly-once automation or transactional apply.
DELETE (204) or POST `{"enabled":false}` (200) disables local callbacks before removing owned
subscriptions. A cleanup failure retains records and credentials for retry; GET must eventually
show `enabled=false` and an empty subscription list. These operations keep the key, imported data
and polling, and do not cancel an in-flight sync. Full disconnect also clears the key after cleanup.
Replace the key or change the callback base only after removing existing subscriptions (otherwise
409); re-registration creates new callback tokens. Shared error and retry policies in §18.7 apply.
### 18.9 Configuration display defaults
This catalog covers saved display settings, separately from provider credential and sync policies:
| Base path | Read | Save | New-record enabled default |
|---|---|---|---|
| `/integrations/gibbs` | `GET /settings` | `POST /settings` | `false` |
Use the integrations-process service with its existing Firebase bearer/session authentication
and global tenant context; these are not runner machine-token endpoints. Navigate to
**Integrations**, then **Add integration** and select the provider. Existing saved values
are preserved; viewing a previously enabled connection does not disable it.
Both operations return `200` with saved settings, including `tenantId`, `enabled` and a
webhook `token` plus persisted record metadata. Treat the token as a secret. Reading a
missing record creates disabled settings and a random token; a missing token on an existing
record is generated and saved. Both read and save also ensure a default booking code-picker
strategy exists if none is configured. These reads therefore have initialization side effects.
Save example, relative to the catalog base path:
```http
POST /settings
Authorization: Bearer <FIREBASE_ID_TOKEN>
Tenant: <TENANT_ID>
Content-Type: application/json
{"enabled":false}
```
The optional body updates only a non-null `enabled` value. Omission preserves the saved
value, or false for a new record. The flag determines configuration display; existing
webhook handlers validate their token independently and do not enforce this flag. Saving
neither registers nor deletes provider subscriptions. Do not interpret a disabled display
as confirmation that provider callbacks have stopped.
Risk: tenant configuration change and possible booking-filter initialization. Confirm the
intended tenant; existing authorization to edit integration settings is sufficient. No
physical action, command queue or configuration event is emitted. Verify with GET and
compare `enabled`. Repeated saves with the same value converge on that value; after an
ambiguous save, read before retrying to avoid overwriting a newer edit. Missing tenant
context returns `400`; authentication failures follow the integrations-process filter.
Malformed JSON returns `400`. For network/server failures, read persisted settings before
retrying. Do not expose tokens in logs or support messages.
## 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",
"primaryColor": "#08788C",
"secondaryColor": "#001838",
"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`. |
| `primaryColor` | Modern-template header and accent color as a six-digit hex code such as `#08788C`. Omitted/null preserves the saved value; blank resets to `#08788C`. GET prefills the default for legacy or new settings. Ignored by other templates. Header text automatically uses a contrasting built-in light or dark color. |
| `secondaryColor` | Modern-template outer-background and main-text color as a six-digit hex code such as `#001838`. Omitted/null preserves the saved value; blank resets to `#001838`. GET prefills the default for legacy or new settings. Ignored by other templates. |
| `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, an invalid
color code, 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.