openapi: 3.0.1
info:
  title: KiiOn API
  version: v1
paths:
  /api/auth/login:
    post:
      tags:
        - Auth
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoginRequestDTO"
        required: true
      responses:
        "200":
          description: A new token pair.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenPair"
        "400":
          description: Malformed request body, or phoneNumber does not match
            ^[+][0-9]{9,13}$.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Wrong credentials. Do not retry with the same values.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429": &a1
          description: Too many requests. Honour Retry-After, then back off exponentially
            with jitter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
      operationId: login
      summary: Log in (phone or email)
      description: "Generic login. Most integrations should use `POST
        /api/auth/login-by-phone`. One active session per account: a new login
        invalidates the previous session."
  /api/auth/login-by-phone:
    post:
      tags:
        - Auth
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoginRequestDTO"
        required: true
      responses:
        "200":
          description: A new token pair.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenPair"
        "400":
          description: Malformed request body, or phoneNumber does not match
            ^[+][0-9]{9,13}$.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Wrong credentials. Do not retry with the same values.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429": *a1
      operationId: loginByPhone
      summary: Log in with phone number and password
      description: Returns an access token (JWT) and a rotating single-use refresh
        token. Log in server-side once and reuse the token — never per unlock.
        One active session per account.
  /api/auth/login-by-email:
    post:
      tags:
        - Auth
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/LoginByEmailRequestDTO"
        required: true
      responses:
        "200":
          description: A new token pair.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenPair"
        "400":
          description: Malformed request body.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: Wrong credentials. Do not retry with the same values.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429": *a1
      operationId: loginByEmail
      summary: Log in with email and password
  /api/auth/refresh:
    post:
      tags:
        - Auth
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefreshSessionRequestDTO"
        required: true
      responses:
        "200":
          description: A new token pair. Both values are new; the old refresh token is
            already dead.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenPair"
        "400":
          description: The refresh token is missing, malformed or already consumed. Do NOT
            retry it — run a full login instead.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: The refresh token is expired or revoked. Run a full login (POST
            /api/auth/login-by-phone). Retrying this token will never succeed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429": *a1
      operationId: refreshToken
      summary: Refresh the session (rotating, single-use)
      description: Exchanges the current refresh token for a NEW access token and a
        NEW refresh token. The token you send is invalid the instant the
        response is issued — persist both returned values in one atomic write.
        Never retry a refresh token that has already been sent.
  /api/auth/logout:
    post:
      tags:
        - Auth
      responses:
        "200":
          description: The session was ended.
        "401": &a2
          description: Access token missing, expired or invalid. Call POST
            /api/auth/refresh once, then replay this request. If it returns 401
            again, the session was superseded — stop and alert an operator; do
            not loop.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
      operationId: logout
      summary: End the current session
      description: "Releases the single active session held by the account. Requires
        `Authorization: Bearer <AccessToken>`."
  /api/device/{deviceId}:
    get:
      tags:
        - Device
      parameters:
        - name: deviceId
          in: path
          required: true
          schema:
            type: integer
            format: int32
      responses:
        "200":
          description: The device's current state.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Device"
        "401": *a2
        "403":
          description: The account holds no key to this device. Retrying will not help.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Unknown deviceId, or it does not belong to this account. Re-fetch
            GET /api/user/device.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429": *a1
      operationId: getDevice
      summary: Read a device's current state
      description: Use this to confirm what happened after an unlock — a 200 from the
        unlock operation means the command was accepted, not that the door
        physically opened.
  /api/device/{deviceId}/unlock:
    put:
      tags:
        - Device
      parameters:
        - name: deviceId
          in: path
          required: true
          schema:
            type: integer
            format: int32
      responses:
        "200":
          description: The unlock command was accepted and forwarded to the device.
        "401": *a2
        "403":
          description: The account holds no key to this device. Retrying will not help;
            contact KiiOn about key sharing.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Unknown deviceId. Re-fetch GET /api/user/device and update your
            mapping.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429": *a1
        "502":
          description: The device or its gateway is unreachable. Do not auto-retry —
            report the failure to the user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: The device is offline or out of range. Do not auto-retry — report
            the failure to the user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "504":
          description: The device did not answer in time. The command may still have
            arrived; do not auto-retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
      operationId: unlockDevice
      summary: Unlock a device
      description: "Sends an unlock command to the device. A 200 means the command was
        ACCEPTED and forwarded — not that the door physically opened; read GET
        /api/device/{deviceId} to confirm state. NOT safe to retry
        automatically: a repeated call can open the door a second time. On
        failure, surface it to a human."
  /api/device/{deviceId}/unlock/{lockId}:
    put:
      tags:
        - Device
      parameters:
        - name: deviceId
          in: path
          required: true
          schema:
            type: integer
            format: int32
        - name: lockId
          in: path
          required: true
          schema:
            type: integer
            format: int32
      responses:
        "200":
          description: The unlock command was accepted and forwarded to the device.
        "401": *a2
        "403":
          description: The account holds no key to this device or lock.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Unknown deviceId or lockId.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429": *a1
        "502":
          description: The device or its gateway is unreachable. Do not auto-retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: The device is offline or out of range. Do not auto-retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "504":
          description: The device did not answer in time. Do not auto-retry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
      operationId: unlockDeviceLock
      summary: Unlock one specific lock on a multi-lock device
      description: Only for devices carrying more than one lock. Single-lock devices
        use PUT /api/device/{deviceId}/unlock. The same no-auto-retry rule
        applies.
  /api/user/location:
    get:
      tags:
        - User
      responses:
        "200":
          description: The locations visible to this account.
        "401": *a2
        "429": *a1
      operationId: listUserLocations
      summary: List the locations this account can reach
  /api/user/device:
    get:
      tags:
        - User
      responses:
        "200":
          description: The devices this account owns or holds a shared key to.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Device"
              example:
                - id: 1001
                  name: Door A
                  locationId: 42
                  locationName: Location 1
                  companyName: Example Inc.
                  doorType: Entrance
                  online: true
        "401": *a2
        "429": *a1
      operationId: listUserDevices
      summary: List the devices this account can reach
      description: The `id` of each entry is the `{deviceId}` used by the unlock
        operations. Cache this mapping — do not call it on the unlock path, and
        never hard-code an id.
components:
  schemas:
    LoginByEmailRequestDTO:
      required:
        - email
        - password
      type: object
      properties:
        email:
          minLength: 1
          type: string
          format: email
        password:
          minLength: 1
          type: string
      additionalProperties: false
    LoginRequestDTO:
      required:
        - password
        - phoneNumber
      type: object
      properties:
        phoneNumber:
          minLength: 1
          pattern: ^[+][0-9]{9,13}$
          type: string
          format: tel
        password:
          minLength: 1
          type: string
      additionalProperties: false
    RefreshSessionRequestDTO:
      required:
        - refreshToken
      type: object
      properties:
        refreshToken:
          minLength: 1
          type: string
      additionalProperties: false
    TokenPair:
      type: object
      description: A session's token pair. Both values change on every refresh;
        persist them together in one atomic write.
      properties:
        AccessToken:
          type: string
          description: "JWT sent as `Authorization: Bearer <AccessToken>`. Its exact
            expiry is the `exp` claim."
        RefreshToken:
          type: string
          description: Single-use. Consumed by the next POST /api/auth/refresh.
      required:
        - AccessToken
        - RefreshToken
      example:
        AccessToken: <AccessToken>
        RefreshToken: <RefreshToken>
    Device:
      type: object
      description: A door/device this account can reach. `id` fills `{deviceId}` in
        the unlock operations.
      properties:
        id:
          type: integer
          format: int32
          description: The deviceId used by the unlock operations.
        name:
          type: string
          nullable: true
          description: The door's name. Map this to your own door identity.
        locationId:
          type: integer
          format: int32
          nullable: true
        locationName:
          type: string
          nullable: true
        companyName:
          type: string
          nullable: true
        doorType:
          type: string
          nullable: true
        online:
          type: boolean
          nullable: true
          description: Whether the device is currently reachable.
      required:
        - id
      example:
        id: 1001
        name: Door A
        locationId: 42
        locationName: Location 1
        companyName: Example Inc.
        doorType: Entrance
        online: true
    ErrorResponse:
      type: object
      description: Error payload. Branch on `status`; the text fields are for logs,
        not for matching.
      properties:
        status:
          type: integer
          format: int32
        title:
          type: string
          nullable: true
        detail:
          type: string
          nullable: true
      required:
        - status
      example:
        status: 401
        title: Unauthorized
        detail: The access token has expired.
  securitySchemes:
    Bearer:
      type: apiKey
      description: 'JWT Authorization header using the Bearer scheme. Example:
        "Authorization: Bearer {token}"'
      name: Authorization
      in: header
security:
  - Bearer: []
servers:
  - url: https://staging.zesec.com
    description: STAGE (test) — integrators develop and test here
  - url: https://api.zesec.com
    description: PROD — verified partners only
