{
  "openapi": "3.0.3",
  "info": {
    "title": "Keyspace Third-Party Application API",
    "version": "1.15.0",
    "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-qat.com",
      "description": "UAT"
    },
    {
      "url": "https://api.keyspace-dev.com",
      "description": "Development"
    },
    {
      "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"
          },
          "503": {
            "$ref": "#/components/responses/AuthTemporarilyUnavailable"
          }
        }
      }
    },
    "/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 — a room whose cleaning time (cleanupMinutes) the window would run into is left out too; 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"
          },
          "503": {
            "$ref": "#/components/responses/AuthTemporarilyUnavailable"
          }
        }
      }
    },
    "/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. A desk whose window would run into a booking's cleaning time (the property's default; see MeetingRoom.cleanupMinutes) is removed too.",
        "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"
          },
          "503": {
            "$ref": "#/components/responses/AuthTemporarilyUnavailable"
          }
        }
      }
    },
    "/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. Back-office bookings (organizerId set by a caller holding MEETING_UPDATE) mint nothing at create: there is no webPaymentUrl, the organizer pays later from their own device, and the booking is held until paymentDueAt (paymentDueMinutes → the project's backofficePaymentDueMinutes → 1440 minutes; clamped to 7 days and to the meeting start). 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. An organizer an admin has blocked from booking is refused 403 MEETING_049 — an authorization refusal, not a slot problem: do not offer another slot.",
        "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"
          },
          "503": {
            "$ref": "#/components/responses/AuthTemporarilyUnavailable"
          },
          "403": {
            "$ref": "#/components/responses/BookingForbidden"
          },
          "409": {
            "$ref": "#/components/responses/TimeSlotConflict"
          },
          "500": {
            "$ref": "#/components/responses/InternalServerError"
          }
        }
      },
      "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"
          },
          "503": {
            "$ref": "#/components/responses/AuthTemporarilyUnavailable"
          }
        }
      }
    },
    "/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. A change to startDateTime/endDateTime by a blocked organizer is refused 403 MEETING_049; an edit that keeps the time (title, body, attendees) is not.",
        "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"
          },
          "403": {
            "$ref": "#/components/responses/BookingBlocked"
          },
          "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"
          },
          "503": {
            "$ref": "#/components/responses/AuthTemporarilyUnavailable"
          },
          "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"
          },
          "503": {
            "$ref": "#/components/responses/AuthTemporarilyUnavailable"
          },
          "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"
          },
          "503": {
            "$ref": "#/components/responses/AuthTemporarilyUnavailable"
          },
          "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"
          },
          "503": {
            "$ref": "#/components/responses/AuthTemporarilyUnavailable"
          },
          "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
            }
          }
        }
      },
      "AuthTemporarilyUnavailable": {
        "description": "Authentication is temporarily unavailable (KS234): the credential could not be checked right now. It is NOT rejected — retry with backoff and do not re-authenticate or discard your tokens.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS234",
              "message": "Authentication is temporarily unavailable. Try again shortly.",
              "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. Also returned when the booking runs into the cleaning time reserved after each booking (the room's cleanupMinutes, or the property's default for rooms and desks); the message then says when the cleaning ends, e.g. \"The meeting room is being cleaned until 11:15. Start at 11:15 or later.\"",
        "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
            }
          }
        }
      },
      "BookingForbidden": {
        "description": "The organizer may not book this asset. MEETING_049: an admin blocked the organizer from booking; do not offer another slot, direct the user to the building admin. KS003: the room or desk is private (isPublic false) and the organizer is not a member of its project; offer a public asset instead",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS003",
              "message": "This room or desk is for project members only.",
              "success": false
            }
          }
        }
      },
      "BookingBlocked": {
        "description": "The organizer has been blocked from booking by an admin (MEETING_049) — an authorization refusal, not a slot or room problem: do not offer another slot; direct the user to the building admin",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "MEETING_049",
              "message": "You are blocked from booking. Contact your building admin to lift the block.",
              "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
            }
          }
        }
      },
      "InternalServerError": {
        "description": "An internal fault — a dropped database connection, a write conflict, or a bug. Transient and safe to retry with backoff (max 3); report if it persists. This is never a statement about the booking itself: the room, desk, time and quota were all acceptable.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            },
            "example": {
              "code": "KS004",
              "message": "Internal Server Error",
              "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"
          },
          "polygon": {
            "type": "array",
            "description": "Optional room outline on the floor-plan image, in drawing order, implicitly closed. Each vertex is a percentage (0-100) of the plan image width (xPct) / height (yPct). Absent, null or [] means no outline; when present it has at least 3 vertices.",
            "items": {
              "type": "object",
              "required": ["xPct", "yPct"],
              "properties": {
                "xPct": { "type": "number", "minimum": 0, "maximum": 100 },
                "yPct": { "type": "number", "minimum": 0, "maximum": 100 }
              }
            }
          },
          "cleanupMinutes": {
            "type": "integer",
            "minimum": 0,
            "maximum": 60,
            "description": "Minutes of cleaning reserved after every booking of this room; 0 = none. Absent means the room follows the property's default (which may be none). A booking holds the room until its end plus the cleaning: the next booking may start no earlier, and a booking must end that long before the next one starts — otherwise create/update answer 409 with a message that says when the cleaning ends. The effective cleaning time can be shorter when the property's booking grid is shorter. An availability search (startDateTime + endDateTime) leaves out a room whose cleaning time the window would run into."
          }
        }
      },
      "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": {
          "organizerId": {
            "type": "string",
            "description": "Book on behalf of this member (back office). The caller must hold MEETING_UPDATE on the asset's project (403 otherwise) and the member must belong to it (400). The member becomes the organizer; the caller is recorded as bookedByUserId and bookedVia marks the booking as back-office. Nothing is minted at create — the organizer pays later from their own device — and promo pricing is evaluated in the organizer's scope. Omit (or pass your own id) for a plain booking."
          },
          "paymentDueMinutes": {
            "type": "integer",
            "minimum": 1,
            "description": "Back-office bookings only (400 otherwise): how long the organizer has to pay, in minutes. Absent ⇒ the project's backofficePaymentDueMinutes, else 1440. Over 10080 (7 days) or past the meeting start is clamped, never rejected. The resolved instant is echoed as paymentDueAt."
          },
          "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": {
          "kind": {
            "type": "string",
            "enum": ["quote", "payment"],
            "description": "\"quote\" = a back-office booking priced at create but not yet minted (no payment_id / invoice_no); the real payment entry is appended when the organizer pays. Absent on entries minted through the gateway."
          },
          "payment_id": {
            "type": "string",
            "description": "Gateway payment id — absent on a quote"
          },
          "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 for a self-service booking, paymentDueAt for a back-office booking; 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"
          },
          "paymentDueAt": {
            "type": "string",
            "format": "date-time",
            "description": "Back-office bookings: the instant the unpaid hold lapses (equals billingSummaries[].expiredAt); the booking auto-cancels past it. Absent on self-service bookings, which keep the ~5-minute window."
          },
          "bookedVia": {
            "type": "string",
            "enum": ["webview", "backoffice", "addin", "outlook-external", "google", "chatbot"],
            "description": "How the booking was created — \"backoffice\" when a caller booked on the organizer's behalf with organizerId"
          },
          "bookedByUserId": {
            "type": "string",
            "description": "The back-office caller who booked on the organizer's behalf (organizerId on create); absent otherwise"
          },
          "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). VMS_INVITE_SENT / VMS_HOST_CANCELLED data.endDateTime is null when the invite has no end time (valid the whole start day). 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",
              "ROOM_PAYMENT_DUE",
              "DESK_BOOK",
              "DESK_MODIFY",
              "DESK_CANCEL",
              "DESK_REMINDER",
              "DESK_NO_SHOW_WARNING",
              "DESK_ENDING_SOON",
              "DESK_PAYMENT_DUE",
              "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>)."
      }
    }
  }
}
