{
  "openapi": "3.1.0",
  "info": {
    "title": "Fluss API",
    "version": "1.2.0",
    "description": "The Fluss public API allows you to control and monitor your Fluss devices programmatically.\n\nAll endpoints require an API key passed via the `authorization` header.\nYou can get an API key by contacting Fluss.\n",
    "contact": {
      "name": "Fluss Support"
    }
  },
  "servers": [
    {
      "url": "https://v1.fluss-api.com",
      "description": "Production"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": [
        "FLUSS_API_KEY"
      ]
    }
  ],
  "tags": [
    {
      "name": "Devices",
      "description": "List and inspect your devices"
    },
    {
      "name": "Logs",
      "description": "View access logs for your devices"
    },
    {
      "name": "Control",
      "description": "Trigger, Open, and Close your devices"
    },
    {
      "name": "Access",
      "description": "Invite users to and revoke users from your devices"
    },
    {
      "name": "SDK",
      "description": "Endpoints consumed by the public Fluss BLE SDK (not for direct use)"
    },
    {
      "name": "User",
      "description": "Read and update the authenticated user's profile"
    }
  ],
  "paths": {
    "/v1/list": {
      "example": null,
      "get": {
        "operationId": "getDeviceList",
        "summary": "List devices",
        "description": "Returns all devices the authenticated user has access to, including device names and the user's permissions for each device.",
        "tags": [
          "Devices"
        ],
        "responses": {
          "200": {
            "description": "List of devices",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "devices": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/DeviceListItem"
                      }
                    }
                  }
                },
                "example": {
                  "devices": [
                    {
                      "deviceId": "abc123",
                      "deviceName": "Front Gate",
                      "userPermissions": {
                        "canOpenMain": true,
                        "canUseWiFi": true,
                        "userType": "owner"
                      }
                    },
                    {
                      "deviceId": "def456",
                      "deviceName": "Side Gate",
                      "userPermissions": {
                        "canOpenMain": true,
                        "canUseWiFi": true,
                        "userType": "tenant"
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/status/{deviceId}": {
      "get": {
        "operationId": "getDeviceStatus",
        "summary": "Get device status",
        "description": "Returns the full status of a device including connectivity, firmware version, signal strength, and open/close state. You must have access to the device.",
        "tags": [
          "Devices"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/deviceId"
          }
        ],
        "responses": {
          "200": {
            "description": "Device status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "$ref": "#/components/schemas/DeviceStatus"
                    }
                  }
                },
                "example": {
                  "status": {
                    "deviceId": "abc123",
                    "internetConnected": true,
                    "connectionTimeStamp": 1711324800000,
                    "updatedTimeStamp": 1711324860000,
                    "ipAddress": "192.168.1.50",
                    "openCloseStatus": "Closed",
                    "fwv": "2.1.4",
                    "ssid": "HomeWifi",
                    "rssi": -42
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "Device does not exist, or no status record is available for it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "deviceNotFound": {
                    "summary": "Device does not exist",
                    "value": {
                      "error": "Device Not Found"
                    }
                  },
                  "noStatus": {
                    "summary": "No status record for device",
                    "value": {
                      "error": "No status found, please ensure your device has a sensor setup and internet connection"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "503": {
            "description": "Device is not connected to the internet.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "Device is not connected to the internet"
                }
              }
            }
          }
        }
      }
    },
    "/v1/logs": {
      "get": {
        "operationId": "getAccessLogs",
        "summary": "Get access logs",
        "description": "Returns access logs for one or more devices within a time range. Results are paginated using a cursor.\n\nThe logs returned depend on the user's permission level for each device:\n- `canViewAccessLogs`: all logs for the device\n- `canUseIntercom`: only the user's own logs\n- `canInviteGuests`: the user's own logs plus logs from guests they invited\n- No special permissions: only the user's own logs\n\nLogs are not returned for timestamps before the device was claimed.\n",
        "tags": [
          "Logs"
        ],
        "parameters": [
          {
            "name": "deviceIds",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Comma-separated list of device IDs.",
            "example": "abc123,def456"
          },
          {
            "name": "start",
            "in": "query",
            "required": true,
            "schema": {
              "type": "number"
            },
            "description": "Start of the time range (epoch ms).",
            "example": 1711324800000
          },
          {
            "name": "stop",
            "in": "query",
            "required": true,
            "schema": {
              "type": "number"
            },
            "description": "End of the time range (epoch ms). Must be greater than `start`.",
            "example": 1711411200000
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Pagination cursor returned from a previous request. Pass this to fetch the next page."
          }
        ],
        "responses": {
          "200": {
            "description": "Access logs for the requested devices",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AccessLogResponse"
                },
                "example": {
                  "results": [
                    {
                      "deviceId": "abc123",
                      "logs": [
                        {
                          "deviceId": "abc123",
                          "timeStamp": 1711324900000,
                          "userId": "user1",
                          "userName": "John Doe",
                          "mobileNumber": "+27821234567",
                          "eventType": "Api Trigger",
                          "metaData": {}
                        }
                      ]
                    }
                  ],
                  "nextCursor": null
                }
              }
            }
          },
          "400": {
            "description": "Missing or invalid query parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "missingDeviceIds": {
                    "summary": "Missing deviceIds",
                    "value": {
                      "error": "deviceIds query parameter is required"
                    }
                  },
                  "invalidNumbers": {
                    "summary": "Invalid start/stop",
                    "value": {
                      "error": "start and stop query parameters are required and must be numbers"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/DeviceNotFound"
          },
          "422": {
            "description": "Invalid time range",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "Invalid time range: stop must be greater than start"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/trigger/{deviceId}": {
      "post": {
        "operationId": "triggerDevice",
        "summary": "Trigger device",
        "description": "Sends a trigger command to the device. This is a general-purpose activation that does not depend on the current open/close state.\n\nRequires `canOpenMain` and `canUseWiFi` permissions. If the user has a schedule, access is validated against it.\n",
        "tags": [
          "Control"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/deviceId"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "metaData": {
                    "type": "string",
                    "maxLength": 400,
                    "description": "Optional metadata to attach to the trigger event. Truncated if over 400 characters."
                  },
                  "timeStamp": {
                    "type": "number",
                    "description": "Optional custom timestamp (epoch ms). Defaults to current time."
                  }
                }
              },
              "example": {
                "metaData": "Triggered via home automation"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Trigger sent",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SuccessResponse"
                },
                "example": {
                  "success": "trigger sent"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/DeviceNotFound"
          },
          "424": {
            "$ref": "#/components/responses/DeviceOffline"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/open/{deviceId}": {
      "post": {
        "operationId": "openDevice",
        "summary": "Open device",
        "description": "Opens the device. The device must currently be in a `closed` state.\n\nReturns `409` if the device is already open. The underlying MQTT command sent is a trigger (`tr`).\n",
        "tags": [
          "Control"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/deviceId"
          }
        ],
        "responses": {
          "200": {
            "description": "Open command sent",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SuccessResponse"
                },
                "example": {
                  "success": "open sent"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/DeviceNotFound"
          },
          "409": {
            "description": "Device is already open",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "Device is already open"
                }
              }
            }
          },
          "424": {
            "$ref": "#/components/responses/DeviceOffline"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/access": {
      "post": {
        "operationId": "giveAccess",
        "summary": "Give access to one or more users on one or more devices",
        "description": "Invites users to one or more devices in a single call. Each invitee is identified by mobile number.\n\n**Device type lock**: every device in the batch must be of the same type. The type is inferred from the deviceId format:\n  - 6 digits → intercom\n  - 16 digits → device\n\nThe first deviceId determines the batch type. Any subsequent deviceIds whose format does not match are skipped — they appear in `results` with each invitee under `rejected` carrying an `error` reason and a message stating the mismatch. If the first deviceId itself has neither shape, the request is rejected with `400`.\n\n**Intercom invites ignore `permission`.** When the batch type is `intercom`, every invitee is granted the canonical `IntercomUser` preset (intercom-only access — no main / pedestrian open, no invite, no logs). Any `permission`, `startDate`, `endDate`, or `schedule` fields you send are silently ignored, and the field becomes optional. For 16-digit (`device`) batches, `permission` is still required and is selected from the presets below — the API expands it server-side to a full `InvitePermissions` object. Allowed values:\n  - `Full` — permission to open, view, invite, revoke. Use for trusted family members or co-managers.\n  - `Always` — permanent open access only. Suitable for live-in residents or staff with no expiry.\n  - `Temporary` — open access bounded by `startDate` and `endDate` (epoch ms). Both fields are required.\n  - `Repeat` — open access governed by a recurring weekly `schedule`. Required when this preset is used.\n\nFor each device, the authenticated caller must have `canInviteUsers` permission.\nFor each invitee, the device's available user slot count (`device.billing.app.usage` minus currently-occupying records) is checked; invitees beyond the slot budget are reported as `rejected` with reason `no_slots`.\n\nEach invitee is resolved to either:\n  - **active**: a registered Fluss user (looked up by mobile) — written to the active access table, push notification enqueued.\n  - **pending**: not registered — written to the pending access table keyed by mobile.\n\nThe following invitees are rejected without aborting the rest of the batch:\n  - The caller themselves (`cannot_change_self`, 409 captured per-invitee).\n  - The device owner (an existing record with `canDelete: true`) (`cannot_change_owner`, 409 captured per-invitee).\n\nEach successful write increments the device's API call count and emits a `Give Access` audit event and a `receiveInvite` notification event.\n\nThe HTTP response is `200` even when some or all invitees are rejected — inspect each device's `accepted` and `rejected` arrays for outcomes. A device-level error (e.g. caller lacks permission on that device) is recorded under that device's `rejected` list rather than failing the whole batch.\n",
        "tags": [
          "Access"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GiveAccessRequest"
              },
              "examples": {
                "alwaysAccess": {
                  "summary": "Permanent open access for two users on two devices",
                  "value": {
                    "deviceIds": [
                      "1111222233334444",
                      "5555666677778888"
                    ],
                    "invitees": [
                      {
                        "userName": "Jane Doe",
                        "mobile": "+27821234567",
                        "notes": "House cleaner"
                      },
                      {
                        "userName": "John Smith",
                        "mobile": "+27839876543",
                        "notes": "Gardener",
                        "unit": "A1"
                      }
                    ],
                    "permission": "Always"
                  }
                },
                "intercomAccess": {
                  "summary": "Intercom batch (permission omitted — IntercomUser is forced)",
                  "value": {
                    "deviceIds": [
                      "123456",
                      "654321"
                    ],
                    "invitees": [
                      {
                        "userName": "Apartment 4B",
                        "mobile": "+27821112222"
                      }
                    ]
                  }
                },
                "temporaryAccess": {
                  "summary": "Bounded access for a one-week visit",
                  "value": {
                    "deviceIds": [
                      "abc123"
                    ],
                    "invitees": [
                      {
                        "userName": "Cousin Pat",
                        "mobile": "+27821112222"
                      }
                    ],
                    "permission": "Temporary",
                    "startDate": 1755648000000,
                    "endDate": 1756252800000
                  }
                },
                "repeatAccess": {
                  "summary": "Weekly recurring access (cleaner — Mon & Wed mornings)",
                  "value": {
                    "deviceIds": [
                      "abc123"
                    ],
                    "invitees": [
                      {
                        "userName": "Cleaner",
                        "mobile": "+27823334444"
                      }
                    ],
                    "permission": "Repeat",
                    "schedule": {
                      "Mon": [
                        {
                          "startTime": "08:00",
                          "endTime": "12:00"
                        }
                      ],
                      "Wed": [
                        {
                          "startTime": "08:00",
                          "endTime": "12:00"
                        }
                      ]
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-device invite outcomes",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GiveAccessResponse"
                },
                "example": {
                  "results": [
                    {
                      "deviceId": "abc123",
                      "accepted": [
                        {
                          "mobile": "+27821234567",
                          "kind": "active",
                          "userAccess": {
                            "deviceId": "abc123",
                            "userId": "user-9",
                            "userName": "Jane Doe",
                            "accessStatus": "sent"
                          }
                        },
                        {
                          "mobile": "+27839876543",
                          "kind": "pending",
                          "userAccess": {
                            "deviceId": "abc123",
                            "mobile": "+27839876543",
                            "userName": "John Smith",
                            "accessStatus": "sent"
                          }
                        }
                      ],
                      "rejected": []
                    },
                    {
                      "deviceId": "def456",
                      "accepted": [
                        {
                          "mobile": "+27821234567",
                          "kind": "active",
                          "userAccess": {
                            "deviceId": "def456",
                            "userId": "user-9"
                          }
                        }
                      ],
                      "rejected": [
                        {
                          "mobile": "+27839876543",
                          "reason": "no_slots"
                        }
                      ]
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Missing or invalid request fields, or unknown / misconfigured permission preset.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "missingFields": {
                    "summary": "deviceIds or invitees missing",
                    "value": {
                      "error": "deviceIds and invitees are required"
                    }
                  },
                  "missingPermission": {
                    "summary": "Permission missing for a 16-digit (device) batch",
                    "value": {
                      "error": "permission is required for device-type access"
                    }
                  },
                  "invalidDeviceIdFormat": {
                    "summary": "First deviceId is neither 6 nor 16 digits",
                    "value": {
                      "error": "invalid deviceId format 'abc'. Expected 6 digits (intercom) or 16 digits (device)"
                    }
                  },
                  "unknownPreset": {
                    "summary": "Permission preset not recognised",
                    "value": {
                      "error": "unknown permission preset 'Owner'. Allowed: Full, Always, Temporary, Repeat"
                    }
                  },
                  "missingTemporaryDates": {
                    "summary": "Temporary preset is missing startDate / endDate",
                    "value": {
                      "error": "Temporary permission requires both startDate and endDate"
                    }
                  },
                  "missingSchedule": {
                    "summary": "Repeat preset is missing schedule",
                    "value": {
                      "error": "Repeat permission requires a non-empty schedule"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "422": {
            "description": "Date range invalid (e.g. `endDate` not after `startDate`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "endDate must be greater than startDate"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/access/{deviceId}": {
      "delete": {
        "operationId": "revokeAccess",
        "summary": "Revoke a single user's access to a device",
        "description": "Revokes access for one user (identified by mobile) on a single device.\n\nThe caller must have `canRevokeAccess` permission on the device.\n\nThe mobile is resolved to either an active registered user (record deleted from the active access table) or a pending invitee (record deleted from the pending access table by mobile).\n\nThe following are refused:\n  - Caller revoking themselves (`cannot_revoke_self`, 409).\n  - Target is the device owner (existing record with `canDelete: true`) (`cannot_revoke_owner`, 409).\n  - Target is registered but has no access record on this device (`revoke_target_not_found`, 404). Pending revokes do not 404 — the delete is idempotent at the table level.\n\nOn success the device's API call count is incremented and a `Revoke Access` audit event is emitted.\n",
        "tags": [
          "Access"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/deviceId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RevokeAccessRequest"
              },
              "example": {
                "mobile": "+27821234567"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Access revoked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RevokeAccessResponse"
                },
                "example": {
                  "success": "access revoked",
                  "result": {
                    "deviceId": "abc123",
                    "mobile": "+27821234567",
                    "kind": "active",
                    "revokedUserId": "user-9"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing `deviceId` path parameter or `mobile` body field.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "deviceId and mobile are required"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller lacks `canRevokeAccess` on this device.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "user does not have permission to revoke access"
                }
              }
            }
          },
          "404": {
            "description": "Target user is registered but has no access record on this device.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "user not registered to the device"
                }
              }
            }
          },
          "409": {
            "description": "Refused — cannot revoke self, or target is the device owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "examples": {
                  "self": {
                    "summary": "Caller tried to revoke their own access",
                    "value": {
                      "error": "cannot revoke your own access"
                    }
                  },
                  "owner": {
                    "summary": "Target is the device owner",
                    "value": {
                      "error": "cannot revoke the owner of the device"
                    }
                  }
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/close/{deviceId}": {
      "post": {
        "operationId": "closeDevice",
        "summary": "Close device",
        "description": "Closes the device. The device must currently be in an `open` state.\n\nReturns `409` if the device is already closed. The underlying MQTT command sent is a trigger (`tr`).\n",
        "tags": [
          "Control"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/deviceId"
          }
        ],
        "responses": {
          "200": {
            "description": "Close command sent",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SuccessResponse"
                },
                "example": {
                  "success": "close sent"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/DeviceNotFound"
          },
          "409": {
            "description": "Device is already closed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "Device is already closed"
                }
              }
            }
          },
          "424": {
            "$ref": "#/components/responses/DeviceOffline"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/sdk/devices": {
      "get": {
        "operationId": "getSdkDevices",
        "summary": "Get the signed device list for the public BLE SDK",
        "description": "Returns the BLE device records the API-key holder is entitled to operate, accompanied by\nan Ed25519-signed envelope. The SDK verifies the signature with its embedded public key\nand uses `payload.deviceIds` as the source of truth; `devices[]` carries the full\nrecords (including the BLE secret tokens) the SDK needs to scan and write.\n\nFiltering: only **owned** or **Full-access** devices are returned. Intercom records and\nany access record without a `secretToken` are excluded.\n\nThe signature covers the UTF-8 bytes of the **base64 payload string** — not the decoded\nJSON — so the SDK can verify without worrying about JSON canonicalization.\n",
        "tags": [
          "SDK"
        ],
        "responses": {
          "200": {
            "description": "Signed device envelope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SdkDevicesResponse"
                },
                "example": {
                  "payload": "eyJkZXZpY2VJZHMiOlsiYWJjMTIzIl0sImlzc3VlZEF0IjoxNzM5OTk5MDAwMDAwLCJleHBpcmVzQXQiOjE3NDI1OTEwMDAwMDAsImN1c3RvbWVySWQiOiJ1c3JfeHl6In0=",
                  "signature": "qf4bV...AA==",
                  "devices": [
                    {
                      "deviceId": "abc123",
                      "deviceName": "Front Gate",
                      "userId": "usr_xyz",
                      "mobile": "+27821234567",
                      "secretToken": "f3c2...",
                      "userIdSecretToken": "f3c2..."
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/sdk/logs": {
      "post": {
        "operationId": "postSdkLog",
        "summary": "Submit an SDK telemetry event",
        "description": "Records a single SDK lifecycle event (scan/write/trigger/error). Sent best-effort by the\nSDK when the consumer initialises with `log = true`; failures must never block BLE\noperations on the device side.\n",
        "tags": [
          "SDK"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SdkLogRequest"
              },
              "example": {
                "event": "trigger",
                "timestamp": 1740000000000,
                "deviceName": "Front Gate",
                "message": "OK"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Log accepted for processing",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "accepted": {
                      "type": "boolean"
                    }
                  }
                },
                "example": {
                  "accepted": true
                }
              }
            }
          },
          "400": {
            "description": "Unknown or missing `event`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/user/profile": {
      "get": {
        "operationId": "getUserProfile",
        "summary": "Get the authenticated user's profile",
        "description": "Returns the authenticated user's profile, including the registered webhook URL when one is set.",
        "tags": [
          "User"
        ],
        "responses": {
          "200": {
            "description": "The user's profile",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "profile": {
                      "$ref": "#/components/schemas/UserProfile"
                    }
                  }
                },
                "example": {
                  "success": true,
                  "profile": {
                    "userId": "user-9",
                    "userName": "Jane Doe",
                    "webhookUrl": "https://example.com/webhooks/fluss"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "No profile found for the authenticated user.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "Profile not found"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "operationId": "updateUserProfile",
        "summary": "Update the authenticated user's profile",
        "description": "Updates the authenticated user's profile.\n\nThe optional `webhookUrl` field registers an HTTPS URL that Fluss will POST to on device\nstatus changes. It must start with `https://` and be a syntactically valid URL. Passing an\nempty string (`\"\"`) removes the webhook entirely.\n",
        "tags": [
          "User"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateUserProfileRequest"
              },
              "example": {
                "webhookUrl": "https://example.com/webhooks/fluss"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated profile",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "profile": {
                      "$ref": "#/components/schemas/UserProfile"
                    }
                  }
                },
                "example": {
                  "success": true,
                  "profile": {
                    "userId": "user-9",
                    "userName": "Jane Doe",
                    "webhookUrl": "https://example.com/webhooks/fluss"
                  }
                }
              }
            }
          },
          "400": {
            "description": "The supplied `webhookUrl` is not a valid https:// URL.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "error": "webhookUrl must be a valid https:// URL"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "x-webhooks": {
    "statusChange": {
      "post": {
        "summary": "Device status change",
        "description": "Fired when a device reports a status change (open, closed, online, offline). Fluss will POST the payload to the user's registered webhookUrl. The request will time out after 10 seconds. Non-2xx responses are logged but not retried.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookStatusChangePayload"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged. Any 2xx is treated as success."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "authorization",
        "description": "Your Fluss API key. Pass it as the `authorization` header value."
      }
    },
    "parameters": {
      "deviceId": {
        "name": "deviceId",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "The unique identifier of the device.",
        "example": "abc123"
      }
    },
    "schemas": {
      "WebhookStatusChangePayload": {
        "type": "object",
        "required": [
          "event",
          "deviceId",
          "status",
          "timestamp"
        ],
        "properties": {
          "event": {
            "type": "string",
            "enum": [
              "status_change"
            ]
          },
          "deviceId": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "closed",
              "online",
              "offline"
            ]
          },
          "timestamp": {
            "type": "number",
            "description": "Unix epoch milliseconds"
          }
        }
      },
      "UserProfile": {
        "type": "object",
        "required": [
          "userId"
        ],
        "properties": {
          "userId": {
            "type": "string"
          },
          "userName": {
            "type": "string"
          },
          "webhookUrl": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS URL to receive webhook POST requests on device status changes. Must start with https://. Omitted if not set.\n"
          }
        }
      },
      "UpdateUserProfileRequest": {
        "type": "object",
        "properties": {
          "webhookUrl": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS URL to receive webhook POST requests on device status changes. Must start with https://. Pass an empty string (\"\") to remove the registered webhook.\n"
          }
        }
      },
      "DeviceListItem": {
        "type": "object",
        "properties": {
          "deviceId": {
            "type": "string"
          },
          "deviceName": {
            "type": "string"
          },
          "userPermissions": {
            "$ref": "#/components/schemas/UserPermissions"
          }
        },
        "required": [
          "deviceId",
          "deviceName",
          "userPermissions"
        ]
      },
      "UserPermissions": {
        "type": "object",
        "properties": {
          "userType": {
            "type": "string",
            "description": "The user's role (e.g. owner, tenant, guest)."
          },
          "canOpenMain": {
            "type": "boolean"
          },
          "canOpenPedestrian": {
            "type": "boolean"
          },
          "canUseWiFi": {
            "type": "boolean"
          },
          "canInviteUsers": {
            "type": "boolean",
            "description": "Required to invite users via `POST /v1/access`."
          },
          "canRevokeAccess": {
            "type": "boolean",
            "description": "Required to revoke users via `DELETE /v1/access/{deviceId}`."
          },
          "canDelete": {
            "type": "boolean",
            "description": "True for the device owner. Owners cannot be revoked or have their permissions overwritten via the access endpoints."
          },
          "canViewAccessLogs": {
            "type": "boolean"
          },
          "canOperateSwitch": {
            "type": "boolean"
          },
          "startDate": {
            "type": "number",
            "description": "Temporary access start (epoch ms)."
          },
          "endDate": {
            "type": "number",
            "description": "Temporary access end (epoch ms). Records past their `endDate` are excluded from the device's user-slot count."
          }
        }
      },
      "NewUserObj": {
        "type": "object",
        "description": "An invitee to be granted access to a device.",
        "properties": {
          "userName": {
            "type": "string",
            "description": "Display name to store on the invitee's access record."
          },
          "mobile": {
            "type": "string",
            "description": "E.164-formatted mobile number used to look up an existing Fluss account or to key the pending record."
          },
          "notes": {
            "type": "string",
            "description": "Free-form notes attached to the invitee's access record."
          },
          "unit": {
            "type": "string",
            "description": "Optional intercom unit identifier. When set, the invitee is also registered against the device's unit list."
          }
        },
        "required": [
          "userName",
          "mobile"
        ]
      },
      "PermissionPreset": {
        "type": "string",
        "enum": [
          "Full",
          "Always",
          "Temporary",
          "Repeat"
        ],
        "description": "Named permission preset that the API expands server-side into a full `InvitePermissions` object.\n\n  - `Full` — open / view / invite / revoke (no Wi-Fi, not the device owner).\n  - `Always` — permanent open access only.\n  - `Temporary` — open access bounded by `startDate` + `endDate` (both required when this preset is used).\n  - `Repeat` — open access governed by a recurring weekly `schedule` (required when this preset is used).\n"
      },
      "ScheduleItem": {
        "type": "object",
        "properties": {
          "startTime": {
            "type": "string",
            "description": "Time of day in `HH:MM` 24h format.",
            "example": "09:00"
          },
          "endTime": {
            "type": "string",
            "description": "Time of day in `HH:MM` 24h format. Must be greater than or equal to `startTime`.",
            "example": "17:00"
          }
        },
        "required": [
          "startTime",
          "endTime"
        ]
      },
      "InviteSchedule": {
        "type": "object",
        "description": "Recurring weekly access schedule used by the `Repeat` preset. Keys are short day names; each value is an array of allowed time ranges for that day.\n",
        "additionalProperties": {
          "type": "array",
          "items": {
            "$ref": "#/components/schemas/ScheduleItem"
          }
        },
        "example": {
          "Mon": [
            {
              "startTime": "08:00",
              "endTime": "12:00"
            }
          ],
          "Wed": [
            {
              "startTime": "08:00",
              "endTime": "12:00"
            }
          ]
        }
      },
      "GiveAccessRequest": {
        "type": "object",
        "properties": {
          "deviceIds": {
            "type": "array",
            "minItems": 1,
            "items": {
              "type": "string"
            },
            "description": "Devices to invite the users to. The first id locks the batch's device type\n(6 digits → intercom, 16 digits → device); mismatched ids are skipped.\n"
          },
          "invitees": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/NewUserObj"
            }
          },
          "permission": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PermissionPreset"
              }
            ],
            "description": "Required when the batch type is `device` (16-digit ids). Ignored when the batch type is `intercom`\n(6-digit ids) — intercom invitees always receive the canonical `IntercomUser` permission set.\n"
          },
          "startDate": {
            "type": "number",
            "description": "Required when `permission` is `Temporary`. Epoch ms. Ignored for intercom batches."
          },
          "endDate": {
            "type": "number",
            "description": "Required when `permission` is `Temporary`. Epoch ms. Must be greater than `startDate`. Ignored for intercom batches."
          },
          "schedule": {
            "allOf": [
              {
                "$ref": "#/components/schemas/InviteSchedule"
              }
            ],
            "description": "Required when `permission` is `Repeat`. Ignored for intercom batches."
          }
        },
        "required": [
          "deviceIds",
          "invitees"
        ]
      },
      "AcceptedInvite": {
        "type": "object",
        "properties": {
          "mobile": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "active",
              "pending"
            ],
            "description": "- `active`: invitee was an existing Fluss user; their active access record was written.\n- `pending`: invitee is not yet a Fluss user; a pending record keyed by mobile was written.\n"
          },
          "userAccess": {
            "type": "object",
            "description": "The newly-written access record (shape depends on `kind`)."
          }
        },
        "required": [
          "mobile",
          "kind"
        ]
      },
      "RejectedInvite": {
        "type": "object",
        "properties": {
          "mobile": {
            "type": "string"
          },
          "reason": {
            "type": "string",
            "enum": [
              "no_slots",
              "error"
            ],
            "description": "- `no_slots`: the device's user-slot budget (`device.billing.app.usage` minus currently-occupying records) was exhausted before this invitee.\n- `error`: per-invitee error such as caller-equals-invitee, owner protection, or upstream lookup failure.\n"
          },
          "error": {
            "type": "string",
            "description": "Present when `reason` is `error`. Human-readable message."
          }
        },
        "required": [
          "mobile",
          "reason"
        ]
      },
      "InviteDeviceResult": {
        "type": "object",
        "properties": {
          "deviceId": {
            "type": "string"
          },
          "accepted": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AcceptedInvite"
            }
          },
          "rejected": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RejectedInvite"
            }
          }
        },
        "required": [
          "deviceId",
          "accepted",
          "rejected"
        ]
      },
      "GiveAccessResponse": {
        "type": "object",
        "properties": {
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/InviteDeviceResult"
            }
          }
        },
        "required": [
          "results"
        ]
      },
      "RevokeAccessRequest": {
        "type": "object",
        "properties": {
          "mobile": {
            "type": "string",
            "description": "E.164-formatted mobile number of the user whose access should be removed."
          }
        },
        "required": [
          "mobile"
        ]
      },
      "RevokeAccessResult": {
        "type": "object",
        "properties": {
          "deviceId": {
            "type": "string"
          },
          "mobile": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "active",
              "pending"
            ]
          },
          "revokedUserId": {
            "type": "string",
            "description": "Present when `kind` is `active`."
          }
        },
        "required": [
          "deviceId",
          "mobile",
          "kind"
        ]
      },
      "RevokeAccessResponse": {
        "type": "object",
        "properties": {
          "success": {
            "type": "string"
          },
          "result": {
            "$ref": "#/components/schemas/RevokeAccessResult"
          }
        },
        "required": [
          "success",
          "result"
        ]
      },
      "DeviceStatus": {
        "type": "object",
        "properties": {
          "deviceId": {
            "type": "string"
          },
          "internetConnected": {
            "type": "boolean",
            "description": "Whether the device is currently online."
          },
          "connectionTimeStamp": {
            "type": "number",
            "description": "Epoch ms when the device last connected."
          },
          "updatedTimeStamp": {
            "type": "number",
            "description": "Epoch ms when the status was last updated."
          },
          "ipAddress": {
            "type": "string"
          },
          "openCloseStatus": {
            "type": "string",
            "enum": [
              "Open",
              "Closed"
            ],
            "description": "Current physical state of the device."
          },
          "fwv": {
            "type": "string",
            "description": "Firmware version."
          },
          "ssid": {
            "type": "string",
            "description": "Wi-Fi network the device is connected to."
          },
          "rssi": {
            "type": "number",
            "description": "Wi-Fi signal strength (dBm)."
          }
        },
        "required": [
          "internetConnected",
          "connectionTimeStamp",
          "updatedTimeStamp"
        ]
      },
      "AccessLogResponse": {
        "type": "object",
        "properties": {
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DeviceAccessLogResult"
            }
          },
          "nextCursor": {
            "type": "string",
            "nullable": true,
            "description": "Pagination cursor for the next page. `null` when there are no more results."
          }
        },
        "required": [
          "results",
          "nextCursor"
        ]
      },
      "DeviceAccessLogResult": {
        "type": "object",
        "properties": {
          "deviceId": {
            "type": "string"
          },
          "logs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AccessLog"
            }
          }
        },
        "required": [
          "deviceId",
          "logs"
        ]
      },
      "AccessLog": {
        "type": "object",
        "properties": {
          "deviceId": {
            "type": "string"
          },
          "timeStamp": {
            "type": "number",
            "description": "Epoch ms when the event occurred."
          },
          "userId": {
            "type": "string"
          },
          "userName": {
            "type": "string"
          },
          "mobileNumber": {
            "type": "string"
          },
          "eventType": {
            "type": "string",
            "description": "The type of event (e.g. \"Api Trigger\", \"Wi-Fi Trigger\", \"Api Open\", \"Api Close\")."
          },
          "metaData": {
            "type": "object",
            "description": "Additional metadata about the event."
          }
        },
        "required": [
          "deviceId",
          "timeStamp",
          "userId",
          "eventType"
        ]
      },
      "SuccessResponse": {
        "type": "object",
        "properties": {
          "success": {
            "type": "string"
          }
        }
      },
      "SdkDeviceRecord": {
        "type": "object",
        "description": "Per-device record consumed by the public Fluss BLE SDK.",
        "properties": {
          "deviceId": {
            "type": "string"
          },
          "deviceName": {
            "type": "string"
          },
          "userId": {
            "type": "string",
            "description": "The Fluss user (API-key holder) the BLE keys are bound to."
          },
          "mobile": {
            "type": "string"
          },
          "secretToken": {
            "type": "string",
            "description": "BLE write key for this device + user pair."
          },
          "userIdSecretToken": {
            "type": "string",
            "description": "Currently identical to `secretToken`; carried separately to match the SDK's\nDeviceAccess shape.\n"
          }
        },
        "required": [
          "deviceId",
          "deviceName",
          "userId",
          "mobile",
          "secretToken",
          "userIdSecretToken"
        ]
      },
      "SdkDevicesResponse": {
        "type": "object",
        "description": "Signed envelope plus the per-device records the SDK uses for BLE writes. The SDK verifies\n`signature` over the **UTF-8 bytes of the base64 `payload` string** (not the decoded JSON)\nusing the embedded Ed25519 public key, then cross-checks `payload.deviceIds` against\n`devices[].deviceId`.\n",
        "properties": {
          "payload": {
            "type": "string",
            "description": "Base64-encoded JSON of `{ deviceIds, issuedAt, expiresAt, customerId }`.\nTokens are valid for 30 days from `issuedAt`.\n"
          },
          "signature": {
            "type": "string",
            "description": "Base64-encoded Ed25519 signature over the UTF-8 bytes of `payload`."
          },
          "devices": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SdkDeviceRecord"
            }
          }
        },
        "required": [
          "payload",
          "signature",
          "devices"
        ]
      },
      "SdkLogRequest": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "enum": [
              "scanStarted",
              "scanStopped",
              "write",
              "trigger",
              "writeFailed",
              "triggerFailed",
              "error"
            ]
          },
          "timestamp": {
            "type": "number",
            "description": "Epoch ms from the SDK clock."
          },
          "deviceName": {
            "type": "string"
          },
          "message": {
            "type": "string"
          }
        },
        "required": [
          "event",
          "timestamp"
        ]
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Invalid or missing API key, or no access to the device.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "access denied: you are not registered to the device"
            }
          }
        }
      },
      "Forbidden": {
        "description": "User does not have permission for this action.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "examples": {
              "wifi": {
                "summary": "Wi-Fi permission denied",
                "value": {
                  "error": "permission denied, user cannot use wifi"
                }
              },
              "main": {
                "summary": "Main trigger permission denied",
                "value": {
                  "error": "permission denied, user cannot use main trigger"
                }
              },
              "schedule": {
                "summary": "Schedule restriction",
                "value": {
                  "error": "Repeat day access denied"
                }
              }
            }
          }
        }
      },
      "DeviceNotFound": {
        "description": "Device does not exist.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "Device Not Found"
            }
          }
        }
      },
      "DeviceOffline": {
        "description": "Device is not connected to the internet.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "Device not connected to internet"
            }
          }
        }
      },
      "InternalError": {
        "description": "An unexpected error occurred.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "error": "Internal Server Error"
            }
          }
        }
      }
    }
  }
}