{
  "openapi": "3.0.3",
  "info": {
    "title": "Keyspace Third-Party Application API",
    "version": "1.11.1",
    "description": "Third-party REST API for the Keyspace Smart Building System: meeting-room and desk search with availability filtering, booking lifecycle (create / list / detail / update / cancel / check-in / check-out), and payment hand-off for paid rooms. Authenticate with OAuth 2.0 client credentials (app_id / app_secret) via POST /auth/accessToken, then send the access token as a Bearer token. Every response except the token endpoint is wrapped in the standard envelope {code, success, message, data, meta?} where code \"KS000\" means success. The full prose documentation is served at /llms-full.txt. Server-to-server webhooks: register a listener with your Keyspace contact and lifecycle events are POSTed to it, HMAC-SHA256 signed — see the WebhookEnvelope schema and the x-webhooks extension. Partner apps that own their users, card numbers and face photos can also provision members and credentials (/partner/members/*) — see the Partner members tag."
  },
  "servers": [
    {
      "url": "https://api.keyspace-dev.com",
      "description": "Development"
    },
    {
      "url": "https://api.keyspace-qat.com",
      "description": "UAT"
    },
    {
      "url": "https://api.keyspace.tech",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/auth/accessToken": {
      "post": {
        "operationId": "getAccessToken",
        "summary": "Obtain or refresh an access token",
        "description": "OAuth 2.0 token endpoint. Use grant_type app_credentials with app_id/app_secret for the initial token, or grant_type refresh_token to rotate. Advanced: grant_type act_as (RFC-8693 token-exchange; the URN urn:ietf:params:oauth:grant-type:token-exchange is accepted as an alias) mints a short-lived, member-scoped token on behalf of one of your OWN federated members — send app_id/app_secret plus subject (default subject_type external_user_id). The delegated token is authorized at member ∩ app scope and carries NO refresh token (re-run act_as instead). Returns the token object directly (NOT wrapped in the standard envelope) and no token_type field — always send the token as `Authorization: Bearer <access_token>`. Token lifetimes are environment configuration: derive expiry from expires_in, never hardcode.",
        "security": [],
        "tags": ["Authentication"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/AppCredentialsGrant"
                  },
                  {
                    "$ref": "#/components/schemas/RefreshTokenGrant"
                  },
                  {
                    "$ref": "#/components/schemas/ActAsGrant"
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Token issued",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TokenResponse"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/meeting-rooms": {
      "get": {
        "operationId": "listMeetingRooms",
        "summary": "List meeting rooms, optionally filtered to a free time window",
        "description": "Not paginated — returns the full filtered set (no meta). Pass startDateTime + endDateTime to get only rooms free to book for that window; rooms free but not bookable for a configuration reason carry unavailableReason (OPERATING_HOURS or BREAK_TIME). Absence of unavailableReason means bookable.",
        "tags": ["Meeting Rooms"],
        "parameters": [
          {
            "name": "projectId",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Project (property) to list rooms for"
          },
          {
            "name": "zoneId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "floorId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "locationId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Restrict to specific room IDs"
          },
          {
            "name": "name",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": ["active", "inactive"]
              }
            }
          },
          {
            "name": "type",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["normal", "bedroom"]
            }
          },
          {
            "name": "minCapacity",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "startDateTime",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Availability window start (ISO 8601 with offset)"
          },
          {
            "name": "endDateTime",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Availability window end — required together with startDateTime"
          }
        ],
        "responses": {
          "200": {
            "description": "Rooms",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/MeetingRoom"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/meeting-rooms/{id}": {
      "get": {
        "operationId": "getMeetingRoom",
        "summary": "Get one meeting room (includes booking calendar)",
        "tags": ["Meeting Rooms"],
        "parameters": [
          {
            "$ref": "#/components/parameters/idParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Room",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/MeetingRoom"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/desks": {
      "get": {
        "operationId": "listDesks",
        "summary": "List desks, optionally filtered to a free time window",
        "description": "Paginated (meta returned). Availability filter: startDateTime plus EXACTLY ONE of timePeriod (preset morning/afternoon/allDay window) or endDateTime (explicit window) — mirrors the POST /meetings desk time rules. Unlike meeting-room availability search, desks with a conflicting booking are removed from the result entirely (meta.total reflects the free count); there is no unavailableReason flag on desks — every desk returned is bookable for the window.",
        "tags": ["Desks"],
        "parameters": [
          {
            "name": "projectId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "brandId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "zoneId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "floorId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "locationId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "ids",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "name",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "email",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["active", "inactive", "in-used", "reserved", "suspended"]
            }
          },
          {
            "name": "statuses",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string",
                "enum": ["active", "inactive", "in-used", "reserved", "suspended"]
              }
            }
          },
          {
            "name": "startDateTime",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Availability window start"
          },
          {
            "name": "endDateTime",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Availability window end (explicit form — exactly one of endDateTime / timePeriod)"
          },
          {
            "name": "timePeriod",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["morning", "afternoon", "allDay"]
            },
            "description": "Preset window form — exactly one of endDateTime / timePeriod"
          },
          {
            "$ref": "#/components/parameters/limitParam"
          },
          {
            "$ref": "#/components/parameters/offsetParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Desks",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Desk"
                          }
                        },
                        "meta": {
                          "$ref": "#/components/schemas/Meta"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/desks/{id}": {
      "get": {
        "operationId": "getDesk",
        "summary": "Get one desk (includes booking calendar)",
        "tags": ["Desks"],
        "parameters": [
          {
            "$ref": "#/components/parameters/idParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Desk",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Desk"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/meetings": {
      "post": {
        "operationId": "createMeeting",
        "summary": "Create a room or desk booking",
        "description": "Exactly one of meetingRoomId / deskId. Desk bookings: exactly one of endDateTime / timePeriod. A response with status \"unpaid\" requires the end user to complete payment at payment.webPaymentUrl within ~5 minutes (billingSummaries[].expiredAt) or the booking auto-cancels — do NOT automate payment. No-show auto-cancel: unless disabled per asset (config.meetingConfig.postStartExpireMinutes = -1), an active booking nobody checks in to is automatically cancelled ~15 minutes after start. No idempotency mechanism: never blind-retry a timed-out create; reconcile via GET /meetings. Slot races surface as 409 KS012.",
        "tags": ["Meetings"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateMeetingRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Booking created (status \"active\") or awaiting payment (status \"unpaid\" with payment object)",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Meeting"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/TimeSlotConflict"
          }
        }
      },
      "get": {
        "operationId": "listMeetings",
        "summary": "List bookings in a time window",
        "description": "Paginated. from/to are required. Use for \"my bookings\" screens, day timelines, payment-confirmation polling, and post-timeout reconciliation.",
        "tags": ["Meetings"],
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "projectId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "meetingRoomId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "meetingRoomIds",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "deskId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "deskIds",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "organizerId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "organizerEmails",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "attendeeEmails",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          },
          {
            "name": "statuses",
            "in": "query",
            "schema": {
              "type": "array",
              "items": {
                "$ref": "#/components/schemas/MeetingStatus"
              }
            }
          },
          {
            "$ref": "#/components/parameters/limitParam"
          },
          {
            "$ref": "#/components/parameters/offsetParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Bookings",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Meeting"
                          }
                        },
                        "meta": {
                          "$ref": "#/components/schemas/Meta"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/meetings/{id}": {
      "get": {
        "operationId": "getMeeting",
        "summary": "Get one booking",
        "description": "Full booking detail including status, pinCode, payment/billing summaries, and histories. Poll this after directing a user to webPaymentUrl until status leaves \"unpaid\".",
        "tags": ["Meetings"],
        "parameters": [
          {
            "$ref": "#/components/parameters/idParam"
          }
        ],
        "responses": {
          "200": {
            "description": "Booking",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Meeting"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "operationId": "updateMeeting",
        "summary": "Update or extend a booking",
        "description": "Only active or checked-in bookings. Start time cannot change after check-in. Extending a paid booking can return status \"unpaid\" with a payment object — same handling as a paid create. pinCode required when the caller is not the organizer.",
        "tags": ["Meetings"],
        "parameters": [
          {
            "$ref": "#/components/parameters/idParam"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateMeetingRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated booking",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Meeting"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/TimeSlotConflict"
          }
        }
      }
    },
    "/meetings/{id}/cancel": {
      "post": {
        "operationId": "cancelMeeting",
        "summary": "Cancel a booking",
        "description": "Allowed from active or unpaid ONLY — a checked-in booking cannot be cancelled; end it with check-out instead. Irreversible. Pending payments are voided. Authorized for the organizer, a caller presenting the booking's pinCode, or a project admin.",
        "tags": ["Meetings"],
        "parameters": [
          {
            "$ref": "#/components/parameters/idParam"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "pinCode": {
                    "type": "string"
                  },
                  "paymentId": {
                    "type": "string",
                    "description": "Payment to void when cancelling an unpaid booking"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Cancelled booking",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Meeting"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/StateConflict"
          }
        }
      }
    },
    "/meetings/{id}/check-in": {
      "post": {
        "operationId": "checkInMeeting",
        "summary": "Check in to a booking",
        "description": "Opens ~15 minutes before start (configurable per room). Only active bookings — paid bookings must complete payment first. Checking in stops the no-show auto-cancel timer. pinCode required when the caller is not the organizer.",
        "tags": ["Meetings"],
        "parameters": [
          {
            "$ref": "#/components/parameters/idParam"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "pinCode": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Checked-in booking",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Meeting"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/StateConflict"
          }
        }
      }
    },
    "/meetings/{id}/check-out": {
      "post": {
        "operationId": "checkOutMeeting",
        "summary": "Check out of a booking (ends it — irreversible)",
        "description": "Only checked-in bookings. Releases the room. Cannot be undone.",
        "tags": ["Meetings"],
        "parameters": [
          {
            "$ref": "#/components/parameters/idParam"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "pinCode": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Completed booking",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Meeting"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "$ref": "#/components/responses/StateConflict"
          }
        }
      }
    },
    "/partner/members/{externalUserId}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/externalUserIdParam"
        }
      ],
      "put": {
        "operationId": "upsertPartnerMember",
        "summary": "Create or update one of YOUR users as a Keyspace member",
        "description": "Idempotent on externalUserId. Resolves WHICH Keyspace member you mean before writing, in this order (first hit wins): (1) the member already linked to this externalUserId; (2) `email` → an existing Keyspace user (case-insensitive); (3) `phone` → an existing user after normalization (Thai numbers, `+66` default); (4) `employeeId` → a member of the project carrying that employee id; (5) nothing matched → a new member is created. A matched user that is not yet a member of your project is added as one. A key that resolves to a DIFFERENT member than the one already linked to this externalUserId — or to a member this app already manages under another externalUserId — is rejected 409 (`data.conflictKey` names the key); Keyspace never silently re-links. `unit` is a BINDING, never an identity: it places the member in the condo unit (first resident = reservation owner, later ones = participants) but never picks the person. First call links the member to your app and adds it to the access groups fixed at app provisioning (partnerAccessGroupIds) — devices only ever receive credentials through those groups (credential ≠ permission). Later calls update the profile. A previously offboarded member is re-activated by this call (same memberId, groups restored). Requires an app_credentials token (a delegated act_as token is rejected 403) and a single-project app.",
        "tags": ["Partner members"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PartnerMemberUpsertRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Member created or updated",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/PartnerMember"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NotYourMember"
          },
          "404": {
            "description": "unit.addressNo / unit.unitId is not a room of your project (KS005). Rooms are created by the building admin, never by the partner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": "KS005",
                  "message": "Unit \"99/99\" is not a room of this project — rooms are created by the building admin, not the partner",
                  "success": false
                }
              }
            }
          },
          "409": {
            "description": "Identity conflict (KS006): an identity key resolves to a different member than the one linked to this externalUserId, or to a member this app already manages under another externalUserId, or the employeeId / email / phone is already taken; `data.conflictKey` names the key (`email` | `phone` | `employeeId` | `unit.addressNo`), `data.linkedExternalUserId` the id it is linked as. Also raised when addressNo exists in several zones and no unit.zoneId was sent.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                },
                "example": {
                  "code": "KS006",
                  "message": "employeeId resolves to a member this app already manages as externalUserId \"office-1\" — update that member instead, or correct the key",
                  "success": false,
                  "data": {
                    "conflictKey": "employeeId",
                    "linkedExternalUserId": "office-1"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "get": {
        "operationId": "getPartnerMember",
        "summary": "Read one of your members — profile, status and credential summary",
        "description": "Never returns face bytes (presence only). Unknown or foreign external ids are 403 KS221; a GET never creates a member.",
        "tags": ["Partner members"],
        "responses": {
          "200": {
            "description": "Member detail",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/PartnerMemberDetail"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NotYourMember"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "delete": {
        "operationId": "offboardPartnerMember",
        "summary": "Offboard one of your members",
        "description": "Deactivates the member in the project: every card (physical AND the auto-generated one) is revoked, device access is withdrawn, and the member is removed from your access groups. The link between your externalUserId and the member is KEPT, so a later PUT re-activates the same member rather than creating a duplicate. Idempotent.",
        "tags": ["Partner members"],
        "responses": {
          "204": {
            "description": "Offboarded (or already offboarded)"
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NotYourMember"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/partner/members/{externalUserId}/credentials": {
      "parameters": [
        {
          "$ref": "#/components/parameters/externalUserIdParam"
        }
      ],
      "put": {
        "operationId": "setPartnerMemberCredentials",
        "summary": "Declaratively set the member’s cards and face",
        "description": "Applies, in order: face (set / erase), the physical card set (enroll missing, revoke absent), then the auto-generated card rule — if the member now has a face and ZERO active physical cards, Keyspace mints ONE virtual card with a random unique number and returns it with generated: true so you can print/label a card. Card numbers default to Hikvision decimal-reversed. Supplying a face REQUIRES faceConsentAt. The member must be active (409 if offboarded — PUT the member first). Devices receive the credentials only through the access groups your app was provisioned with.",
        "tags": ["Partner members"],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PartnerCredentialsRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Resulting credential set",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/PartnerCredentials"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ValidationError"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/NotYourMember"
          },
          "409": {
            "$ref": "#/components/responses/StateConflict"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Access token from POST /auth/accessToken"
      }
    },
    "parameters": {
      "idParam": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "MongoDB ObjectId"
      },
      "limitParam": {
        "name": "limit",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 0,
          "maximum": 3000
        },
        "description": "Page size"
      },
      "offsetParam": {
        "name": "offset",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 0,
          "default": 0
        },
        "description": "Items to skip"
      },
      "externalUserIdParam": {
        "name": "externalUserId",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "maxLength": 128
        },
        "description": "YOUR stable identifier for the user (opaque to Keyspace). Bound to exactly one Keyspace member per app on first PUT."
      }
    },
    "responses": {
      "ValidationError": {
        "description": "Validation failed — message is an array of constraint strings",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS001",
              "message": ["startDateTime must be a valid ISO 8601 date string"],
              "success": false
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or expired token",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS002",
              "message": "Unauthorized",
              "success": false
            }
          }
        }
      },
      "NotFound": {
        "description": "Resource not found",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS005",
              "message": "Not Found",
              "success": false
            }
          }
        }
      },
      "TimeSlotConflict": {
        "description": "Reservation time-slot conflict — expected under contention, recoverable by offering another slot",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS012",
              "message": "Reservation time slot conflict",
              "success": false
            }
          }
        }
      },
      "StateConflict": {
        "description": "Request contradicts the booking's current state",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS006",
              "message": "Conflict",
              "success": false
            }
          }
        }
      },
      "NotYourMember": {
        "description": "The app is not authorized for this member (unknown external id, someone else’s member, or outside the app project scope), or the app is not a single-project app",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS221",
              "message": "The app is not authorized to manage this member",
              "success": false
            }
          }
        }
      },
      "Forbidden": {
        "description": "Forbidden — the token is not an app_credentials token (delegated / staff tokens are rejected)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS003",
              "message": "Forbidden",
              "success": false
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limited — 30 requests per 60 s per app on the partner surface",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS036",
              "message": "Too Many Requests",
              "success": false
            }
          }
        }
      }
    },
    "schemas": {
      "AppCredentialsGrant": {
        "type": "object",
        "required": ["app_id", "app_secret", "grant_type"],
        "properties": {
          "app_id": {
            "type": "string"
          },
          "app_secret": {
            "type": "string"
          },
          "grant_type": {
            "type": "string",
            "enum": ["app_credentials"]
          }
        }
      },
      "RefreshTokenGrant": {
        "type": "object",
        "required": ["refresh_token", "grant_type"],
        "properties": {
          "refresh_token": {
            "type": "string"
          },
          "grant_type": {
            "type": "string",
            "enum": ["refresh_token"]
          }
        }
      },
      "ActAsGrant": {
        "type": "object",
        "description": "ADR-0022 partner delegation. The app authenticates with its own credentials and asserts a subject (one of ITS OWN federated members) to receive a short-lived, member-scoped, refresh-less token authorized at member ∩ app scope. act-as by external_user_id is available for single-project apps (the member is JIT-provisioned on first use); a multi-project app must use subject_type keyspace_member_id for an already-linked member. Errors: 403 KS221 (the app is not authorized to act as this subject), 401 KS223 (subject could not be resolved).",
        "required": ["app_id", "app_secret", "subject", "grant_type"],
        "properties": {
          "app_id": {
            "type": "string"
          },
          "app_secret": {
            "type": "string"
          },
          "subject": {
            "type": "string",
            "description": "The member to act as — your external_user_id (default) or a Keyspace member id."
          },
          "subject_type": {
            "type": "string",
            "enum": ["external_user_id", "keyspace_member_id"],
            "default": "external_user_id"
          },
          "grant_type": {
            "type": "string",
            "enum": ["act_as", "urn:ietf:params:oauth:grant-type:token-exchange"]
          }
        }
      },
      "TokenResponse": {
        "type": "object",
        "description": "NOT wrapped in the standard envelope. No token_type field — always send as a Bearer token.",
        "properties": {
          "access_token": {
            "type": "string"
          },
          "expires_in": {
            "type": "integer",
            "description": "Access-token lifetime in seconds — environment configuration, never hardcode"
          },
          "refresh_token": {
            "type": "string"
          },
          "refresh_token_expires_in": {
            "type": "integer",
            "description": "Refresh-token lifetime in seconds"
          }
        }
      },
      "Envelope": {
        "type": "object",
        "description": "Standard response envelope (every endpoint except /auth/accessToken)",
        "properties": {
          "code": {
            "type": "string",
            "example": "KS000"
          },
          "success": {
            "type": "boolean"
          },
          "message": {
            "type": "string"
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "description": "KSxxx error code — branch on HTTP status plus this; message text is not a stable contract"
          },
          "message": {
            "oneOf": [
              {
                "type": "string"
              },
              {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "400 KS001 validation: one string per violated constraint"
              }
            ]
          },
          "success": {
            "type": "boolean",
            "enum": [false]
          }
        }
      },
      "Meta": {
        "type": "object",
        "properties": {
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          },
          "total": {
            "type": "integer"
          }
        }
      },
      "MeetingStatus": {
        "type": "string",
        "enum": ["unpaid", "active", "checked-in", "done", "cancelled", "rejected"],
        "description": "unpaid → active → checked-in → done; cancelled reachable from unpaid (cancel / ~5-min payment lapse) and active (cancel / no-show auto-cancel ~15 min after start) ONLY — a checked-in booking cannot be cancelled, it ends via check-out or automatically at end time; rejected = declined by calendar provider or admin (terminal). Statuses change without an API call (auto-cancels, auto-done) — re-fetch to reconcile."
      },
      "TimeSlot": {
        "type": "object",
        "properties": {
          "start": {
            "type": "string",
            "example": "09:00:00",
            "description": "Property-local wall-clock HH:mm:ss"
          },
          "end": {
            "type": "string",
            "example": "18:00:00"
          }
        }
      },
      "DayTimeSlotConfig": {
        "type": "object",
        "properties": {
          "availableTime": {
            "$ref": "#/components/schemas/TimeSlot"
          },
          "breakTimes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TimeSlot"
            },
            "description": "Booking is blocked during these"
          }
        }
      },
      "AssetConfig": {
        "type": "object",
        "properties": {
          "dayConfig": {
            "type": "object",
            "description": "Source of truth for operating hours. Per-weekday; a missing day means not bookable that day.",
            "properties": {
              "Mon": {
                "$ref": "#/components/schemas/DayTimeSlotConfig"
              },
              "Tue": {
                "$ref": "#/components/schemas/DayTimeSlotConfig"
              },
              "Wed": {
                "$ref": "#/components/schemas/DayTimeSlotConfig"
              },
              "Thu": {
                "$ref": "#/components/schemas/DayTimeSlotConfig"
              },
              "Fri": {
                "$ref": "#/components/schemas/DayTimeSlotConfig"
              },
              "Sat": {
                "$ref": "#/components/schemas/DayTimeSlotConfig"
              },
              "Sun": {
                "$ref": "#/components/schemas/DayTimeSlotConfig"
              }
            }
          },
          "start": {
            "type": "string",
            "description": "Legacy overall opening time — prefer dayConfig"
          },
          "end": {
            "type": "string",
            "description": "Legacy overall closing time — prefer dayConfig"
          },
          "minimumLeadTime": {
            "type": "object",
            "properties": {
              "minutes": {
                "type": "integer"
              },
              "hours": {
                "type": "integer"
              },
              "days": {
                "type": "integer"
              }
            }
          },
          "maxAdvanceBookingDays": {
            "type": "integer"
          },
          "maxDurationMinutes": {
            "type": "integer"
          },
          "bookMultipleDayRoom": {
            "type": "boolean"
          },
          "meetingConfig": {
            "type": "object",
            "description": "Notification / auto-cancel timings for this asset, in minutes. -1 disables the tick. Defaults when absent: preStart 15, postStartWarn 10, postStartExpire 15 (no-show auto-cancel), preEnd 15.",
            "properties": {
              "preStartMinutes": {
                "type": "integer"
              },
              "postStartWarnMinutes": {
                "type": "integer"
              },
              "postStartExpireMinutes": {
                "type": "integer",
                "description": "Minutes after start before an un-checked-in active booking is auto-cancelled; -1 disables"
              },
              "preEndMinutes": {
                "type": "integer"
              }
            }
          }
        }
      },
      "ChargeRate": {
        "type": "object",
        "properties": {
          "perHour": {
            "type": "number"
          },
          "perDay": {
            "type": "number"
          }
        }
      },
      "MeetingRoom": {
        "type": "object",
        "properties": {
          "_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "capacity": {
            "type": "integer"
          },
          "status": {
            "type": "string",
            "enum": ["active", "inactive"]
          },
          "type": {
            "type": "string",
            "enum": ["normal", "bedroom"]
          },
          "brandId": {
            "type": "string"
          },
          "projectId": {
            "type": "string"
          },
          "zoneId": {
            "type": "string"
          },
          "floorId": {
            "type": "string"
          },
          "equipmentsAvailability": {
            "type": "object",
            "properties": {
              "camera": {
                "type": "boolean"
              },
              "display": {
                "type": "boolean"
              },
              "mic": {
                "type": "boolean"
              },
              "projector": {
                "type": "boolean"
              },
              "speaker": {
                "type": "boolean"
              }
            }
          },
          "pictureUrls": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "facilities": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "isPublic": {
            "type": "boolean"
          },
          "chargeRate": {
            "$ref": "#/components/schemas/ChargeRate"
          },
          "config": {
            "$ref": "#/components/schemas/AssetConfig"
          },
          "unavailableReason": {
            "type": "string",
            "enum": ["OPERATING_HOURS", "BREAK_TIME"],
            "description": "Present only in availability searches when the room is free but not bookable for the requested window; absent = bookable"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Desk": {
        "type": "object",
        "properties": {
          "_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "index": {
            "type": "integer"
          },
          "email": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["active", "inactive", "in-used", "reserved", "suspended"]
          },
          "isPublic": {
            "type": "boolean"
          },
          "brandId": {
            "type": "string"
          },
          "projectId": {
            "type": "string"
          },
          "zoneId": {
            "type": "string"
          },
          "floorId": {
            "type": "string"
          },
          "capacity": {
            "type": "integer"
          },
          "facilities": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "pictureUrls": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "chargeRate": {
            "$ref": "#/components/schemas/ChargeRate"
          },
          "config": {
            "$ref": "#/components/schemas/AssetConfig"
          }
        }
      },
      "AttendeeRequest": {
        "type": "object",
        "required": ["email"],
        "properties": {
          "email": {
            "type": "string",
            "format": "email"
          },
          "id": {
            "type": "string",
            "description": "Keyspace user ID, if known"
          },
          "type": {
            "type": "string",
            "enum": ["required", "optional"],
            "default": "required"
          }
        }
      },
      "CreateMeetingRequest": {
        "type": "object",
        "required": ["title", "startDateTime"],
        "properties": {
          "meetingRoomId": {
            "type": "string",
            "description": "Exactly one of meetingRoomId / deskId"
          },
          "deskId": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "startDateTime": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 with explicit timezone offset"
          },
          "endDateTime": {
            "type": "string",
            "format": "date-time",
            "description": "Required for rooms; desk bookings take exactly one of endDateTime / timePeriod"
          },
          "timePeriod": {
            "type": "string",
            "enum": ["morning", "afternoon", "allDay"],
            "description": "Desk bookings only"
          },
          "attendees": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AttendeeRequest"
            }
          },
          "estimatedAttendance": {
            "type": "integer",
            "minimum": 0
          },
          "paymentChannel": {
            "type": "string",
            "enum": ["prompt-pay", "credit-card", "alipay", "true-money", "we-chat"],
            "default": "prompt-pay"
          },
          "discountCode": {
            "type": "string"
          },
          "frontendRedirectUrl": {
            "type": "string",
            "format": "uri",
            "description": "Where the payer is redirected after completing web payment — use your app's return/deep-link URL"
          },
          "draft": {
            "type": "boolean",
            "default": false,
            "description": "true = price preview only, nothing is created"
          },
          "draftEndDateTimes": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date-time"
            },
            "description": "With draft: preview prices for several candidate end times in one call"
          }
        }
      },
      "UpdateMeetingRequest": {
        "type": "object",
        "properties": {
          "title": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "startDateTime": {
            "type": "string",
            "format": "date-time",
            "description": "Cannot change after check-in"
          },
          "endDateTime": {
            "type": "string",
            "format": "date-time"
          },
          "timePeriod": {
            "type": "string",
            "enum": ["morning", "afternoon", "allDay"]
          },
          "attendees": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AttendeeRequest"
            }
          },
          "estimatedAttendance": {
            "type": "integer",
            "minimum": 0
          },
          "pinCode": {
            "type": "string",
            "description": "Required when the caller is not the organizer"
          },
          "paymentChannel": {
            "type": "string",
            "enum": ["prompt-pay", "credit-card", "alipay", "true-money", "we-chat"]
          },
          "discountCode": {
            "type": "string"
          },
          "frontendRedirectUrl": {
            "type": "string",
            "format": "uri"
          },
          "draft": {
            "type": "boolean",
            "default": false
          },
          "draftEndDateTimes": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "date-time"
            }
          }
        }
      },
      "Payment": {
        "type": "object",
        "properties": {
          "paymentId": {
            "type": "string"
          },
          "invoiceNo": {
            "type": "string"
          },
          "amount": {
            "type": "number"
          },
          "summary": {
            "type": "object",
            "properties": {
              "subtotal": {
                "type": "number"
              },
              "total": {
                "type": "number"
              },
              "discounts": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "code": {
                      "type": "string"
                    },
                    "value": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "webPaymentUrl": {
            "type": "string",
            "format": "uri",
            "description": "Human web payment flow — present to the end user, do not automate"
          },
          "webPaymentQr": {
            "type": "string",
            "description": "data: URI PNG QR code"
          }
        }
      },
      "BillingSummary": {
        "type": "object",
        "properties": {
          "payment_id": {
            "type": "string"
          },
          "amount": {
            "type": "number"
          },
          "total": {
            "type": "number"
          },
          "invoice_no": {
            "type": "string"
          },
          "status": {
            "type": "string"
          },
          "expiredAt": {
            "type": "string",
            "format": "date-time",
            "description": "Payment window end (~5 minutes after create) — unpaid bookings auto-cancel past this"
          }
        }
      },
      "Meeting": {
        "type": "object",
        "properties": {
          "_id": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/MeetingStatus"
          },
          "meetingRoomId": {
            "type": "string"
          },
          "meetingRoomName": {
            "type": "string"
          },
          "deskId": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "startDateTime": {
            "type": "string",
            "format": "date-time"
          },
          "endDateTime": {
            "type": "string",
            "format": "date-time"
          },
          "pinCode": {
            "type": "string",
            "description": "Sensitive — required for update/cancel/check-in/check-out by non-organizers; never log"
          },
          "organizer": {
            "type": "object",
            "properties": {
              "userId": {
                "type": "string"
              },
              "email": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "displayName": {
                "type": "string"
              },
              "checkInDateTime": {
                "type": "string",
                "format": "date-time"
              },
              "checkOutDateTime": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          "attendees": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "userId": {
                  "type": "string"
                },
                "email": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "type": {
                  "type": "string",
                  "enum": ["required", "optional", "resource"]
                },
                "responseStatus": {
                  "type": "string",
                  "enum": ["accepted", "declined", "none", "tentativelyAccepted"]
                },
                "checkInDateTime": {
                  "type": "string",
                  "format": "date-time"
                }
              }
            }
          },
          "payment": {
            "$ref": "#/components/schemas/Payment"
          },
          "billingSummaries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BillingSummary"
            }
          },
          "histories": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "action": {
                  "type": "string",
                  "enum": [
                    "create",
                    "update",
                    "cancel",
                    "check-in",
                    "check-out",
                    "pay",
                    "payment-timeout",
                    "rejected",
                    "cancel-updating"
                  ]
                },
                "at": {
                  "type": "string",
                  "format": "date-time"
                },
                "by": {
                  "type": "string"
                }
              }
            }
          },
          "seriesMasterId": {
            "type": "string",
            "description": "Present ONLY on a projected occurrence of a recurring series, returned by GET /meetings when from/to are supplied. Equals the series master's _id (the same value as this row's _id). Its presence is how you tell a projected occurrence from an ordinary booking."
          },
          "occurrenceEventId": {
            "type": "string",
            "description": "Present ONLY on a projected occurrence. Stable and unique per occurrence — use it as the list key / de-duplication handle. _id is NOT unique across the occurrences of one series."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "WebhookEnvelope": {
        "type": "object",
        "description": "Body POSTed to a registered partner webhook listener. Covers booking (ROOM_/DESK_) and visitor (VMS_) lifecycle events on one channel. Headers: X-Keyspace-Event (type), X-Keyspace-Delivery (= id, unique per delivery — de-duplicate on it), X-Keyspace-Timestamp (epoch ms), X-Keyspace-Signature: sha256=HEX(HMAC-SHA256(secret, timestamp + '.' + rawBody)) computed over the exact raw bytes. Respond 2xx within 10s; retries: 5 attempts exponential from ~60s; 20 consecutive exhausted deliveries auto-disable the webhook. data is a change signal — for booking events fetch authoritative state via GET /meetings/{id}; visitor events are terminal (no partner GET). data never carries a meeting pinCode/tokens, EXCEPT VMS_INVITE_SENT which deliberately includes qrCardCode (the visitor's access QR, to relay to the invitee — treat as a credential). Tolerate unknown data fields.",
        "properties": {
          "id": {
            "type": "string",
            "example": "whk_7f8a1f4e-1234-4b3c-9d2e-abcdef012345"
          },
          "type": {
            "type": "string",
            "enum": [
              "ROOM_BOOK",
              "ROOM_MODIFY",
              "ROOM_CANCEL",
              "ROOM_REMINDER",
              "ROOM_NO_SHOW_WARNING",
              "ROOM_ENDING_SOON",
              "DESK_BOOK",
              "DESK_MODIFY",
              "DESK_CANCEL",
              "DESK_REMINDER",
              "DESK_NO_SHOW_WARNING",
              "DESK_ENDING_SOON",
              "VMS_INVITE_SENT",
              "VMS_VISITOR_CHECKED_IN",
              "VMS_HOST_CANCELLED"
            ]
          },
          "version": {
            "type": "integer",
            "enum": [1]
          },
          "occurredAt": {
            "type": "string",
            "format": "date-time"
          },
          "projectId": {
            "type": "string"
          },
          "data": {
            "type": "object",
            "description": "Event-specific fields — full field-by-field spec + sample JSON per event type in the 'Per-event payload reference' section of /llms-full.txt. Always includes projectId/userId (hex ids) + timestamp (ISO 8601). Cancels carry cancelReason: absent = user/admin cancel; 'No-show — auto-cancelled by system'; 'Payment window lapsed — auto-cancelled by system'.",
            "additionalProperties": true
          }
        }
      },
      "PartnerMemberUpsertRequest": {
        "type": "object",
        "required": ["firstName", "lastName"],
        "properties": {
          "firstName": {
            "type": "string",
            "maxLength": 120
          },
          "lastName": {
            "type": "string",
            "maxLength": 120,
            "description": "Required; may be an empty string for single-name members."
          },
          "profileName": {
            "type": "string",
            "maxLength": 120,
            "description": "Display name. Derived from first/last name when omitted on first creation."
          },
          "email": {
            "type": "string",
            "format": "email",
            "description": "Omitted = untouched. Must be unique across Keyspace users; a clash is rejected (409 KS-duplicate code)."
          },
          "phone": {
            "type": "string",
            "description": "E.164 (+66…). Omitted = untouched. Unique across Keyspace users. Identity key #3: matched after normalization (spaces/dashes stripped, Thai `+66` default)."
          },
          "employeeId": {
            "type": "string",
            "minLength": 1,
            "maxLength": 64,
            "description": "Office identity key #4 — your HR / badge id. Matched against members of the project; unique within the project (409). Valid on its own (no email / phone): the member gets no login. Omitted = untouched."
          },
          "unit": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/PartnerUnitInput"
              },
              {
                "type": "null"
              }
            ],
            "description": "Condo unit BINDING (not an identity). Object = place the member in that unit (moving from another unit unbinds it first); null = unbind; omitted = untouched."
          }
        }
      },
      "PartnerUnitInput": {
        "type": "object",
        "required": ["addressNo"],
        "properties": {
          "addressNo": {
            "type": "string",
            "maxLength": 64,
            "description": "House / unit number as the building admin entered it on the room (e.g. \"12/34\"). Unknown = 404; present in several zones without zoneId = 409."
          },
          "zoneId": {
            "type": "string",
            "description": "Zone (tower) id — disambiguates addressNo across zones."
          },
          "unitId": {
            "type": "string",
            "maxLength": 64,
            "description": "Alternate lookup key (the room's unitId) when addressNo does not match."
          }
        }
      },
      "PartnerUnit": {
        "type": "object",
        "required": ["roomId", "addressNo", "groupId", "role"],
        "properties": {
          "roomId": {
            "type": "string"
          },
          "addressNo": {
            "type": "string"
          },
          "groupId": {
            "type": "string",
            "description": "The unit's member group (created on first sight, named after addressNo)."
          },
          "role": {
            "type": "string",
            "enum": ["owner", "participant"],
            "description": "Derived from the room reservation after the write: the first resident bound to a unit owns it, later ones are participants (family). Moving out of a unit removes the member from that reservation."
          }
        }
      },
      "PartnerMember": {
        "type": "object",
        "properties": {
          "memberId": {
            "type": "string",
            "description": "The Keyspace member id (usable as subject_type keyspace_member_id in act_as)."
          },
          "externalUserId": {
            "type": "string"
          },
          "projectId": {
            "type": "string"
          },
          "created": {
            "type": "boolean",
            "description": "true only when THIS call created the member."
          },
          "matchedBy": {
            "type": "string",
            "enum": ["link", "email", "phone", "employeeId", "provisioned"],
            "description": "How the member was identified: the existing link for this externalUserId, one of the identity keys, or a fresh member (provisioned)."
          },
          "accessGroupIds": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Access groups the member holds after the call — the FIXED set your app was provisioned with (partnerAccessGroupIds). You never choose devices or groups."
          },
          "employeeId": {
            "type": "string"
          },
          "unit": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/PartnerUnit"
              },
              {
                "type": "null"
              }
            ],
            "description": "The member's condo unit binding after the call; null when none."
          }
        }
      },
      "PartnerCardInput": {
        "type": "object",
        "required": ["cardNo"],
        "properties": {
          "cardNo": {
            "type": "string",
            "maxLength": 64,
            "description": "The card number as your reader emits it, in cardCodeFormat."
          },
          "cardCodeFormat": {
            "type": "string",
            "enum": [
              "decimal-reversed",
              "decimal",
              "hex",
              "hex-reversed",
              "hex-3byte",
              "hex-3byte-reversed",
              "decimal-3byte",
              "decimal-3byte-reversed",
              "decimal-3byte-10d",
              "decimal-3byte-10d-reversed",
              "split-2h3d-4h5d",
              "split-2h3d-4h5d-reversed",
              "split-4h5d-4h5d",
              "split-4h5d-4h5d-reversed"
            ],
            "default": "decimal-reversed",
            "description": "Reader output format. Default decimal-reversed = Hikvision (8H-10D-R, the 10-digit number a Hik reader shows). Override only for other reader vendors."
          },
          "label": {
            "type": "string",
            "maxLength": 120
          }
        }
      },
      "PartnerCredentialsRequest": {
        "type": "object",
        "description": "Declarative set of the member’s partner-managed credentials. Every field is independent: omit a field to leave that credential type untouched.",
        "properties": {
          "cards": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "$ref": "#/components/schemas/PartnerCardInput"
            },
            "description": "The EXACT set of physical cards the member should hold. Omitted = physical cards untouched; [] = revoke every physical card; a list = enroll the missing ones, revoke the absent ones (matched on the decoded card UID, so the same card in two formats is one card). The auto-generated card is never part of this set."
          },
          "face": {
            "type": "string",
            "nullable": true,
            "description": "Base64 JPEG or PNG (bare base64 or a data:image/…;base64, URL), max 10 MB decoded; type is verified by magic bytes. Omitted = untouched; null = erase the stored face (PDPA); a string = set/replace — REQUIRES faceConsentAt."
          },
          "faceConsentAt": {
            "type": "string",
            "format": "date-time",
            "description": "ISO-8601 instant the member consented to biometric processing. Mandatory whenever face carries an image (400 otherwise); persisted for audit."
          }
        }
      },
      "PartnerCard": {
        "type": "object",
        "properties": {
          "cardId": {
            "type": "string"
          },
          "cardNo": {
            "type": "string",
            "description": "Always the Hikvision decimal-reversed (8H-10D-R) 10-digit number, regardless of the format you submitted."
          },
          "label": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": ["real", "virtual"]
          },
          "generated": {
            "type": "boolean",
            "description": "true for the ONE card Keyspace auto-generates when the member has a face but no physical card — print/label a card with its cardNo. It is never generated twice and is not retired when you later supply physical cards."
          }
        }
      },
      "PartnerCredentials": {
        "type": "object",
        "properties": {
          "memberId": {
            "type": "string"
          },
          "cards": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PartnerCard"
            }
          },
          "face": {
            "type": "object",
            "properties": {
              "present": {
                "type": "boolean"
              }
            },
            "description": "Presence only — face bytes are never returned."
          }
        }
      },
      "PartnerMemberDetail": {
        "type": "object",
        "properties": {
          "memberId": {
            "type": "string"
          },
          "externalUserId": {
            "type": "string"
          },
          "projectId": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": ["active", "deactivated"]
          },
          "source": {
            "type": "string",
            "enum": [
              "partner",
              "federated",
              "admin",
              "brand_admin",
              "owner",
              "oauth_microsoft",
              "oauth_line",
              "vms",
              "meeting",
              "legacy"
            ],
            "description": "Immutable provenance of the project membership: `partner` when this surface created the member, `federated` when the subject was first seen through an act_as grant, otherwise the admin-console / SSO door an existing member came through (a resolved member keeps its own source)."
          },
          "userType": {
            "type": "string",
            "enum": ["member", "visitor"],
            "description": "Current classification. Members created through /partner/members are `member`; act_as-only subjects are `visitor` until an admin promotes them."
          },
          "accessGroupIds": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "employeeId": {
            "type": "string"
          },
          "unit": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/PartnerUnit"
              },
              {
                "type": "null"
              }
            ]
          },
          "profile": {
            "type": "object",
            "properties": {
              "firstName": {
                "type": "string"
              },
              "lastName": {
                "type": "string"
              },
              "profileName": {
                "type": "string"
              },
              "email": {
                "type": "string"
              },
              "phone": {
                "type": "string"
              }
            }
          },
          "credentials": {
            "type": "object",
            "properties": {
              "cards": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/PartnerCard"
                }
              },
              "face": {
                "type": "object",
                "properties": {
                  "present": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "x-webhooks": {
    "bookingLifecycle": {
      "post": {
        "tags": ["Webhooks"],
        "summary": "Booking or visitor lifecycle event delivered to your registered listener URL",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookEnvelope"
              },
              "examples": {
                "roomBook": {
                  "summary": "ROOM_BOOK — booking confirmed",
                  "value": {
                    "id": "whk_7f8a1f4e-1234-4b3c-9d2e-abcdef012345",
                    "type": "ROOM_BOOK",
                    "version": 1,
                    "occurredAt": "2026-07-15T06:55:03.412Z",
                    "projectId": "68faf513142742089f0fb40f",
                    "data": {
                      "projectId": "68faf513142742089f0fb40f",
                      "userId": "66691e95a1b2c3d4e5f60719",
                      "timestamp": "2026-07-15T06:55:03.412Z",
                      "roomName": "Boardroom 7F",
                      "floorName": "7F",
                      "bookingDate": "2026-07-15T07:00:00.000Z",
                      "startTime": "14:00",
                      "endTime": "15:00",
                      "attendees": ["Anan P.", "Beam K."],
                      "userName": "Anan P.",
                      "userEmail": "anan@partner.co"
                    }
                  }
                },
                "roomCancelNoShow": {
                  "summary": "ROOM_CANCEL — no-show auto-cancel (cancelReason set)",
                  "value": {
                    "id": "whk_2c1d0b9a-5678-4c3d-8e1f-fedcba098765",
                    "type": "ROOM_CANCEL",
                    "version": 1,
                    "occurredAt": "2026-07-15T07:15:00.201Z",
                    "projectId": "68faf513142742089f0fb40f",
                    "data": {
                      "projectId": "68faf513142742089f0fb40f",
                      "userId": "66691e95a1b2c3d4e5f60719",
                      "timestamp": "2026-07-15T07:15:00.201Z",
                      "roomName": "Boardroom 7F",
                      "floorName": "7F",
                      "bookingDate": "2026-07-15T07:00:00.000Z",
                      "cancelReason": "No-show — auto-cancelled by system"
                    }
                  }
                },
                "vmsInviteSent": {
                  "summary": "VMS_INVITE_SENT — qrCardCode is the visitor's access QR (treat as a credential)",
                  "value": {
                    "id": "whk_9e8d7c6b-9abc-4d5e-af01-123456789abc",
                    "type": "VMS_INVITE_SENT",
                    "version": 1,
                    "occurredAt": "2026-07-15T03:00:11.008Z",
                    "projectId": "68faf513142742089f0fb40f",
                    "data": {
                      "projectId": "68faf513142742089f0fb40f",
                      "userId": "66691e95a1b2c3d4e5f60720",
                      "timestamp": "2026-07-15T03:00:11.008Z",
                      "visitorName": "Jane Visitor",
                      "visitorEmail": "jane@guest.co",
                      "hostName": "Anan P.",
                      "hostEmail": "anan@partner.co",
                      "visitDate": "2026-07-16T02:00:00.000Z",
                      "startDateTime": "16 Jul 2026 09:00",
                      "endDateTime": "16 Jul 2026 12:00",
                      "qrCardCode": "QR-ACCESS-8F3A21"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged — respond within 10 seconds; anything else is retried"
          }
        },
        "description": "Booking or visitor lifecycle event delivered to your registered listener URL. Always HMAC-signed (X-Keyspace-Signature). If your endpoint requires its own auth, Keyspace also sends a credential per the webhook's authStrategy: 'signature-only' (none), 'bearer' (Authorization: Bearer <token>), 'custom-header' (one or more <name>: <value> headers), or 'jwt' (Authorization: Bearer <short-lived HS256 JWT signed with the shared secret; claims iss=keyspace, aud=projectId, jti=delivery id, exp>)."
      }
    }
  }
}
