# Keyspace Third-Party Application API Documentation **Version 1.15.0** ## Table of Contents 1. [Introduction](#introduction) 2. [API Conventions](#api-conventions) 3. [Authentication](#authentication) - [Provision members and credentials (partner-managed)](#5-provision-members-and-credentials-partner-managed) 4. [Meeting Rooms](#meeting-rooms) 5. [Desks](#desks) 6. [Meeting Operations](#meeting-operations) - [Create Meeting](#create-meeting) - [List Meetings](#list-meetings) - [Get Meeting Detail](#get-meeting-detail) - [Update/Extend Meeting](#updateextend-meeting) - [Cancel Meeting](#cancel-meeting) - [Check-In to Meeting](#check-in-to-meeting) - [Check-Out from Meeting](#check-out-from-meeting) 7. [Meeting Lifecycle](#meeting-lifecycle) 8. [Notifications & Automatic Status Transitions](#notifications--automatic-status-transitions) 9. [Receiving Events — Webhooks](#receiving-events--webhooks) 10. [Error Handling](#error-handling) 11. [Best Practices](#best-practices) 12. [Example Integration Workflow](#example-integration-workflow) 13. [Changelog](#changelog) --- ## Introduction This documentation is for third-party developers integrating room and desk booking with the Keyspace Smart Building System. The API is RESTful and uses OAuth 2.0 client-credentials authentication. | Environment | Base URL | | ----------- | ------------------------------ | | Development | `https://api.keyspace-dev.com` | | UAT | `https://api.keyspace-qat.com` | | Production | `https://api.keyspace.tech` | Use the environment your credentials were issued for. Credentials are not shared across environments. --- ## API Conventions ### Response envelope Every JSON response (except the token endpoint) is wrapped in a common envelope: **Success:** ```json { "code": "KS000", "success": true, "message": "Success", "data": { "...": "..." }, "meta": { "limit": 20, "offset": 0, "total": 42 } } ``` - `code` — `"KS000"` on success; a `KSxxx` error code otherwise (see [Error Handling](#error-handling)). - `data` — the payload (object or array). - `meta` — present only on paginated list endpoints: `{ limit, offset, total }`. **Error:** ```json { "code": "KS012", "message": "Reservation time slot conflict", "success": false } ``` ### HTTP status codes - `GET` endpoints return `200 OK`. - `POST /auth/accessToken` and `POST /meetings` return `201 Created`. - `PATCH /meetings/:id` returns `200 OK`. - `POST /meetings/:id/cancel`, `/check-in`, and `/check-out` return `202 Accepted`. Treat any `2xx` as success; branch error handling on the status code plus the body `code` field. ### Date-times and identifiers - All request date-times are ISO 8601 **with an explicit timezone offset**, e.g. `2026-07-15T14:00:00+07:00`. Seconds are truncated to the start of the minute. - Times inside room configuration (`config.dayConfig`, `config.start`, `config.end`) are wall-clock times local to the property, formatted `HH:mm:ss`. - All entity IDs (`projectId`, `meetingRoomId`, `deskId`, meeting `_id`, …) are MongoDB ObjectId strings. ### Pagination List endpoints that support pagination accept `limit` (default from server config, max 3000) and `offset` (default 0), and return the `meta` object. In this API surface, `GET /meetings` and `GET /desks` are paginated; `GET /meeting-rooms` is **not** (it returns the full filtered set). --- ## Authentication ### Overview Third-party applications use the OAuth 2.0 client-credentials flow. Keyspace issues you: - `app_id` — your application's unique identifier - `app_secret` — your application's secret key Keep both server-side only. Never embed them in a mobile app, browser bundle, or repository. ### 1. Generate Access Token **Endpoint:** `POST /auth/accessToken` **Request Headers:** ```http Content-Type: application/json ``` **Request Body:** ```json { "app_id": "your_app_id_here", "app_secret": "your_app_secret_here", "grant_type": "app_credentials" } ``` **Response (Success — 201 Created):** ```json { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_in": 3600, "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token_expires_in": 604800 } ``` > This endpoint returns the token object directly — it is **not** wrapped in the standard response envelope, and it does not include a `token_type` field. Always send the token as `Authorization: Bearer `. **Response Fields:** - `access_token` — bearer token for API requests - `expires_in` — access-token lifetime **in seconds** - `refresh_token` — token used to obtain a new access token - `refresh_token_expires_in` — refresh-token lifetime **in seconds** > **Do not hardcode token lifetimes.** They are environment configuration and differ between development, UAT, and production. Always compute expiry from the `expires_in` / `refresh_token_expires_in` values in the response. **cURL Example:** ```bash curl -X POST https://api.keyspace-qat.com/auth/accessToken \ -H "Content-Type: application/json" \ -d '{ "app_id": "your_app_id_here", "app_secret": "your_app_secret_here", "grant_type": "app_credentials" }' ``` ### 2. Using the Access Token Include the access token in the `Authorization` header of every API request: ```http Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` ### 3. Refresh Access Token **Endpoint:** `POST /auth/accessToken` (same endpoint, different grant) **Request Body:** ```json { "refresh_token": "your_refresh_token_here", "grant_type": "refresh_token" } ``` The response has the same shape as the initial token response, including a **new** refresh token — always store the newest pair. **Token lifecycle guidance:** - Refresh proactively: if the access token expires within the next 5 minutes, refresh before sending the request. - If a request returns `401` (`KS002`) or the refresh fails, re-authenticate once with `app_credentials`, retry the request once, then propagate the error. - A `503` with `KS234` means **authentication is temporarily unavailable**: the token could not be checked right now — it was not rejected. Retry the same request with backoff (e.g. 1 s, 2 s, 4 s); do not re-authenticate and do not discard your tokens. The refresh grant can answer `KS234` too; keep the refresh token and retry it. - Store tokens securely (environment variables or a secrets vault). Never write tokens or credentials to logs. ### 4. Act on behalf of a member (`act_as`) — advanced Most integrations act purely as the **app** with the `app_credentials` token above. If instead you want your end-users to reuse Keyspace's **per-user** scope (member booking quotas, member-owned resources, per-member roles), you can mint a **member-scoped delegated token** without ever giving your users a Keyspace password — the app vouches for them. This is a coordinated feature: your app must be provisioned by Keyspace with the right project scope first. Talk to your Keyspace contact before using it. **Endpoint:** `POST /auth/accessToken` (same endpoint, `act_as` grant). The RFC-8693 URN `urn:ietf:params:oauth:grant-type:token-exchange` is accepted as an alias for `act_as`. **Request Body:** ```json { "grant_type": "act_as", "app_id": "your_app_id", "app_secret": "your_app_secret", "subject": "your-own-user-id-123", "subject_type": "external_user_id" } ``` - `subject` is **your** identifier for the user (`subject_type: "external_user_id"`, the default). On first use the member is created and linked to your app automatically; the same `subject` always maps to the same member. For a member you've already linked you may instead pass its Keyspace member id with `subject_type: "keyspace_member_id"`. - `external_user_id` act-as requires a **single-project** app. A multi-project app must use `keyspace_member_id`. **Response:** the same token object, but with **no `refresh_token`** — a delegated token is short-lived and not refreshable by design. When it expires, call `act_as` again. **What the delegated token can do:** its authority is the **intersection** of the member's scope and your app's scope — it can never do more than your app itself is allowed to. Use it exactly like any access token (`Authorization: Bearer `). **Errors:** `403 KS221` — your app is not authorized to act as that subject (not one of your linked members, or outside your project scope). `401 KS223` — the subject could not be resolved. ### 5. Provision members and credentials (partner-managed) If your application is the **system of record for its users** — you hold their profile, their access-card numbers and their face photos — you can push them into Keyspace and let Keyspace perform the device enrollment. This is the surface built for building-app partners (Kuhu / Sansiri class): your app creates and maintains the member; Keyspace owns the devices. **Principal:** the plain **`app_credentials` token** (§1). A delegated `act_as` token, a staff token or any other principal is rejected `403`. Your app must be a **single-project** app (the same rule as `act_as` by `external_user_id`). **Ownership:** every route is keyed by **your** user id (`externalUserId`, opaque to Keyspace, max 128 chars). The first `PUT` binds it to exactly one Keyspace member; every later call — including `act_as` with the same `subject` — resolves to that member. You can only ever reach members your own app created: an unknown or foreign id is `403 KS221` on read, credentials and delete (it never creates). **Access is fixed at provisioning — you never pick devices or groups.** When Keyspace provisions your app it attaches a set of access groups (`partnerAccessGroupIds`). Every member you create is added to those groups automatically, and that membership is the ONLY thing that makes devices receive the member's cards and face (*credential ≠ permission*). If you need a different set of doors, ask your Keyspace contact to change the app's groups; there is no API for it. **Members, not guests.** Members created through this surface are project members (`userType: member`, provenance `source: partner`); subjects created only through `act_as` are visitors (`source: federated`) until an admin promotes them. A member you *resolve* rather than create (`email` / `phone` / `employeeId` match) keeps whatever `source` the admin console or SSO gave it. `GET /partner/members/{externalUserId}` returns both fields. **Rate limit:** 30 requests per 60 s per app across these routes → `429 KS036`. #### `PUT /partner/members/{externalUserId}` — create or update a member ```json { "firstName": "Somchai", "lastName": "Jaidee", "profileName": "Chai", "email": "somchai@example.com", "phone": "+66812345678", "employeeId": "HR-0042", "unit": { "addressNo": "12/34", "zoneId": "66a1…" } } ``` - `firstName` and `lastName` are required (an empty `lastName` is accepted). Everything else is optional; **omitted = untouched**. - `email` / `phone` must be unique across Keyspace users — a clash is `409`. `employeeId` (1–64 chars, your HR / badge id) must be unique within the project — a clash is `409`. It is stored as the member's employee id for the staff console; it is NOT what the door terminals see. - Idempotent: the first call creates + links + grants the fixed groups; later calls update the profile. A member you previously offboarded (`DELETE`) is **re-activated by this call** — same `memberId`, groups restored. **Which member do you mean? — identity keys.** Buildings already have members (created by the admin console, a bulk import, or Office sign-in). Before writing, Keyspace resolves `externalUserId` to a member in this order — **first hit wins**: 1. the member already **linked** to this `externalUserId` (your previous PUT); 2. `email` → an existing Keyspace user (case-insensitive); 3. `phone` → an existing user, after normalization (spaces/dashes stripped, Thai numbers default to `+66`); 4. `employeeId` → a member of the project carrying that employee id; 5. nothing matched → a **new** member is created (`created: true`). A user matched in step 2–3 who is not yet a member of your project is added as one. **`unit` is never an identity key** — it says where the member lives, not who they are: a PUT carrying only a `unit` always creates a new member, even if someone already owns that unit. **Conflicts (`409 KS006`, `data.conflictKey` names the key).** Keyspace never silently re-links: - a key on the body resolves to a **different** member than the one already linked to this `externalUserId`; - a key resolves to a member this app already manages under **another** `externalUserId` (`data.linkedExternalUserId` tells you which) — e.g. two of your users sharing one `employeeId`; - `employeeId` / `email` / `phone` already taken by someone else; - `unit.addressNo` exists in several zones (towers) and you sent no `unit.zoneId`. **By vertical.** - *Office / workplace apps* — send `employeeId`; `email` too when the tenant uses Office sign-in, so the member the add-in later signs in as is the same one you provisioned (Keyspace uses the tenant's default role for such emails). `employeeId` **alone** is valid: the member is created without a login (username `{projectId}_{employeeId}`, the bulk-import convention). - *Condo / residence apps* — identify the person with `email`/`phone` (or nothing, on first sight) and **bind** the home with `unit`: `{ addressNo, zoneId?, unitId? }`. `addressNo` is the house number the building admin entered on the room (`404` if the project has no such room — partners never create rooms); `unitId` is an alternate lookup key. The **first** resident bound to a unit becomes the unit's reservation **owner**; later ones are **participants** (family). Moving a member to another unit removes them from the old one; `unit: null` unbinds. **Response `200`:** ```json { "code": "KS000", "success": true, "data": { "memberId": "66b1f0a2c9e77a0012345678", "externalUserId": "your-user-id-123", "projectId": "66a0…", "created": true, "matchedBy": "provisioned", "accessGroupIds": ["66c2…"], "employeeId": "HR-0042", "unit": { "roomId": "66d3…", "addressNo": "12/34", "groupId": "66d4…", "role": "owner" } } } ``` `matchedBy` is `link` | `email` | `phone` | `employeeId` | `provisioned`. `unit` is `null` when the member has no unit binding; `unit.role` is derived from the room reservation **after** the write (`owner` | `participant`). `accessGroupIds` is the set the member actually holds after the call — if it is empty, the app has no (active) partner groups yet: contact Keyspace before relying on device access. #### `PUT /partner/members/{externalUserId}/credentials` — set cards and face A **declarative** set. Each field is independent; **omit a field to leave that credential type untouched.** ```json { "cards": [ { "cardNo": "2140450341", "label": "Resident card" }, { "cardNo": "25AE947F", "cardCodeFormat": "hex" } ], "face": "data:image/jpeg;base64,/9j/4AAQSkZJRg…", "faceConsentAt": "2026-09-01T09:30:00.000Z" } ``` - **`cards`** — the EXACT physical cards the member should hold. Missing ones are enrolled, absent ones are revoked, common ones are kept. `[]` revokes every physical card. Matching is on the decoded 4-byte card UID, so the same card submitted in two formats is one card. Max 20 per member. - `cardNo` is read in **`cardCodeFormat`**, which defaults to **`decimal-reversed` — the Hikvision format (8H-10D-R)**: the 10-digit number a Hik reader/terminal shows for the card. Override per card only if your reader is a different vendor (`decimal`, `hex`, `hex-reversed`, 3-byte and split variants — see the OpenAPI enum). An unparseable number is `400`. - `label` is optional (defaults to `Card `). - **`face`** — a **base64 JPEG or PNG** (bare or `data:image/…;base64,`), max **10 MB decoded**; the type is verified by magic bytes, not by your prefix. `null` **erases** the stored face (PDPA right to erasure). A string sets/replaces it and **requires `faceConsentAt`** — the ISO-8601 instant the person consented to biometric processing; the request is `400` without it, and the timestamp is persisted for audit. Keyspace compresses and stores the image privately; the bytes are never returned by any partner route. - **Auto-generated card.** After applying the set, if the member **has a face and holds ZERO active physical cards**, Keyspace mints **one** virtual card with a random, project-unique number and returns it with `generated: true`. Print/label a physical card with its `cardNo` and hand it to the member — it opens the same doors as an enrolled card. It is **never generated twice**, and physical cards you supply later do **not** retire it. (Offboarding revokes it; a re-onboarded member gets a fresh number — the old card stays dead.) **Response `200`:** ```json { "code": "KS000", "success": true, "data": { "memberId": "66b1f0a2c9e77a0012345678", "cards": [ { "cardId": "66c3…", "cardNo": "2140450341", "label": "Resident card", "type": "real", "generated": false }, { "cardId": "66c4…", "cardNo": "0982716354", "label": "auto-generated (face) 1B2C3D4E", "type": "virtual", "generated": true } ], "face": { "present": true } } } ``` `cardNo` in every response is **always the Hikvision decimal-reversed number**, whatever format you submitted. Requires the member to be active — `409` when offboarded (re-`PUT` the member first). #### `GET /partner/members/{externalUserId}` — read Returns `memberId`, `externalUserId`, `projectId`, `status` (`active` | `deactivated`), `source` (how the membership was created — `partner` for members this surface made, `federated` for `act_as`-only subjects, otherwise the admin/SSO door it came through), `userType` (`member` | `visitor`), `accessGroupIds`, `employeeId`, `unit` (as in the PUT response, or `null`), `profile` (`firstName`, `lastName`, `profileName`, `email`, `phone`) and `credentials` (`cards` as above, `face: { present }`). Never creates a member. #### `DELETE /partner/members/{externalUserId}` — offboard `204`. 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 to your `externalUserId` is **kept**, so a later `PUT` re-activates the same member instead of creating a duplicate. Idempotent. **Errors on this surface:** `403 KS003` — not an app token · `403 KS221` — not your member / not a single-project app · `401 KS223` — the member could not be resolved · `400 KS001` — validation (missing consent, bad card number, bad image) · `404 KS005` — `unit` names a room the project does not have · `409 KS006` — member offboarded (credentials), duplicate email/phone/employeeId, or an identity-key conflict (`data.conflictKey`, see the PUT) · `429 KS036` — rate limited. --- ## Meeting Rooms ### List Meeting Rooms (with availability search) **Endpoint:** `GET /meeting-rooms` **Request Headers:** ```http Authorization: Bearer {access_token} ``` **Query Parameters:** | Parameter | Type | Required | Description | | --------------- | -------- | -------- | ----------------------------------------------------------------------- | | `projectId` | string | **Yes** | Project (property) to list rooms for | | `zoneId` | string | No | Filter by zone | | `floorId` | string | No | Filter by floor | | `locationId` | string | No | Filter by location | | `ids` | string[] | No | Restrict to specific room IDs | | `name` | string | No | Filter by room name | | `status` | string[] | No | `active`, `inactive` (repeatable) | | `type` | string | No | `normal`, `bedroom` | | `minCapacity` | number | No | Only rooms with at least this capacity | | `startDateTime` | string | No | Availability window start (ISO 8601 with offset) — see below | | `endDateTime` | string | No | Availability window end — required together with `startDateTime` | > **Availability search:** pass `startDateTime` + `endDateTime` to get only rooms that are free to book for that window. Rooms with a conflicting booking are excluded from the result — including a room whose cleaning time (`cleanupMinutes`, below) the window would run into. Rooms that are free but not bookable for that window for a configuration reason are returned with an `unavailableReason` field (e.g. `"OPERATING_HOURS"` — outside the room's operating hours, or `"BREAK_TIME"` — the window overlaps a configured break). Absence of `unavailableReason` means the room is bookable at that time. > > This is the recommended way to drive a "pick a room for this time slot" UX. A `409 KS012` on create can still occur if another booking wins a race for the same slot — always handle it. **Response (Success — 200 OK):** *not paginated — no `meta`* ```json { "code": "KS000", "success": true, "message": "Success", "data": [ { "_id": "60d5ec49f1b2c72b8c8e4a1b", "name": "Conference Room A", "email": "conf-room-a@company.com", "capacity": 10, "status": "active", "type": "normal", "brandId": "60d5ec49f1b2c72b8c8e4a1a", "projectId": "60d5ec49f1b2c72b8c8e4a1c", "zoneId": "60d5ec49f1b2c72b8c8e4a1d", "floorId": "60d5ec49f1b2c72b8c8e4a1e", "equipmentsAvailability": { "camera": true, "display": true, "mic": true, "projector": true, "speaker": true }, "pictureUrls": ["https://cdn.example.com/room-a-1.jpg"], "facilities": ["WiFi", "Whiteboard", "Video Conferencing"], "isPublic": true, "cleanupMinutes": 15, "chargeRate": { "perHour": 100, "perDay": 600, "amount": 100 }, "config": { "dayConfig": { "Mon": { "availableTime": { "start": "09:00:00", "end": "18:00:00" }, "breakTimes": [{ "start": "12:00:00", "end": "13:00:00" }] }, "Tue": { "availableTime": { "start": "09:00:00", "end": "18:00:00" } }, "Wed": { "availableTime": { "start": "09:00:00", "end": "18:00:00" } }, "Thu": { "availableTime": { "start": "09:00:00", "end": "18:00:00" } }, "Fri": { "availableTime": { "start": "09:00:00", "end": "17:00:00" } } }, "start": "09:00:00", "end": "18:00:00", "minimumLeadTime": { "minutes": 15, "hours": 0, "days": 0 }, "maxAdvanceBookingDays": 30, "maxDurationMinutes": 240, "bookMultipleDayRoom": false }, "createdAt": "2023-06-25T10:00:00.000Z", "updatedAt": "2023-06-25T10:00:00.000Z" } ] } ``` **Room `config` fields:** | Field | Description | | ----------------------- | ------------------------------------------------------------------------------------------------------------ | | `dayConfig` | **Source of truth for operating hours.** Per-weekday (`Mon`…`Sun`) `availableTime` `{start, end}` plus optional `breakTimes[]` during which booking is blocked. A missing day means the room is not bookable that day. | | `start` / `end` | Legacy overall operating hours. Kept for backward compatibility — prefer `dayConfig` when present. | | `minimumLeadTime` | Minimum advance notice for a booking: `{minutes, hours, days}` before start time | | `maxAdvanceBookingDays` | How far in the future a booking may start | | `maxDurationMinutes` | Maximum booking length, when configured | | `bookMultipleDayRoom` | Whether a single booking may span multiple days | | `meetingConfig` | Notification/auto-cancel timings for this asset: `preStartMinutes`, `postStartWarnMinutes`, `postStartExpireMinutes` (no-show auto-cancel; `-1` disables), `preEndMinutes` — see [Notifications & Automatic Status Transitions](#notifications--automatic-status-transitions) | Times in `dayConfig`, `start`, and `end` are property-local wall-clock times (`HH:mm:ss`). **Cleaning time — `cleanupMinutes`.** A room may reserve minutes of cleaning after every booking: `cleanupMinutes`, an integer 0–60 (`0` = no cleaning). A booking then holds the room until its end plus the cleaning. With 15 minutes of cleaning, a 10:00–11:00 booking keeps the room until 11:15, so the next booking may start at 11:15 at the earliest — and a booking before it must end by 09:45. A room without `cleanupMinutes` follows the property's default, which may be none — the property default applies to desks too. The effective cleaning time can be shorter when the property's booking grid is shorter. The availability search above accounts for it: a room is left out when the window starts inside a booking's cleaning, or when its own cleaning would run into the next booking — so a room it lists as free is not refused for cleaning time (see [Create Meeting](#create-meeting)). **cURL Example — rooms free tomorrow 14:00–15:00:** ```bash curl -G "https://api.keyspace-qat.com/meeting-rooms" \ -H "Authorization: Bearer {access_token}" \ --data-urlencode "projectId=60d5ec49f1b2c72b8c8e4a1c" \ --data-urlencode "status=active" \ --data-urlencode "startDateTime=2026-07-15T14:00:00+07:00" \ --data-urlencode "endDateTime=2026-07-15T15:00:00+07:00" ``` ### Get Single Meeting Room **Endpoint:** `GET /meeting-rooms/:id` Returns one room in the same shape as the list entry, including its booking calendar for availability display. --- ## Desks Desks are bookable workstations. Listing and detail are below; **booking a desk uses the same `POST /meetings` endpoint** with `deskId` instead of `meetingRoomId` (see [Create Meeting](#create-meeting)). Both the listing availability filter and booking take the time as either an explicit `startDateTime`/`endDateTime` window or a `startDateTime` + preset `timePeriod` — exactly one of the two forms. ### List Desks (with availability search) **Endpoint:** `GET /desks` **Query Parameters:** | Parameter | Type | Required | Description | | --------------- | -------- | -------- | ------------------------------------------------------------- | | `projectId` | string | No | Filter by project | | `brandId` | string | No | Filter by brand | | `zoneId` | string | No | Filter by zone | | `floorId` | string | No | Filter by floor | | `locationId` | string | No | Filter by location | | `ids` | string[] | No | Restrict to specific desk IDs | | `name` | string | No | Filter by desk name | | `email` | string | No | Filter by desk resource email | | `status` | string | No | One of `active`, `inactive`, `in-used`, `reserved`, `suspended` | | `statuses` | string[] | No | Multiple statuses | | `startDateTime` | string | No | Availability window start (ISO 8601 with offset) | | `endDateTime` | string | No* | Availability window end — explicit window form | | `timePeriod` | string | No* | `morning`, `afternoon`, `allDay` — preset window form. \*With `startDateTime`, provide exactly one of `endDateTime` / `timePeriod` | | `limit` | number | No | Page size (max 3000) | | `offset` | number | No | Items to skip (default 0) | > **Availability search:** pass `startDateTime` **plus exactly one** of `endDateTime` (explicit window) or `timePeriod` (`morning` / `afternoon` / `allDay`, resolved against the desk's operating hours) to get only desks that are free for that window. This is the desk equivalent of the meeting-room availability search, with two differences: > > - A `timePeriod` preset is accepted (rooms take an explicit window only). > - Desks with a conflicting booking are **removed from the result entirely** (`meta.total` reflects the free count), rather than returned with an `unavailableReason` flag as rooms are. Every desk in the response is bookable for the requested window. > - The property's cleaning time counts as taken here too: a desk is removed when the window starts inside a booking's cleaning, or its own cleaning would run into the next booking (desks have no `cleanupMinutes` of their own — they follow the property's default; see [Meeting Rooms](#meeting-rooms)). > > Omit all three time params for a plain, unfiltered desk listing. **cURL Example — desks free tomorrow morning:** ```bash curl -G "https://api.keyspace-qat.com/desks" \ -H "Authorization: Bearer {access_token}" \ --data-urlencode "projectId=60d5ec49f1b2c72b8c8e4a1c" \ --data-urlencode "status=active" \ --data-urlencode "startDateTime=2026-07-15T00:00:00+07:00" \ --data-urlencode "timePeriod=morning" ``` **Response (Success — 200 OK):** paginated — includes `meta`. ```json { "code": "KS000", "success": true, "message": "Success", "meta": { "limit": 20, "offset": 0, "total": 12 }, "data": [ { "_id": "60d5ec49f1b2c72b8c8e4b10", "name": "Hot Desk 01", "index": 1, "email": "desk-01@company.com", "status": "active", "isPublic": true, "brandId": "60d5ec49f1b2c72b8c8e4a1a", "projectId": "60d5ec49f1b2c72b8c8e4a1c", "zoneId": "60d5ec49f1b2c72b8c8e4a1d", "floorId": "60d5ec49f1b2c72b8c8e4a1e", "capacity": 1, "facilities": ["Monitor", "USB-C Dock"], "pictureUrls": [], "config": { "dayConfig": { "Mon": { "availableTime": { "start": "08:00:00", "end": "18:00:00" } } }, "minimumLeadTime": { "minutes": 0, "hours": 0, "days": 0 } } } ] } ``` ### Get Single Desk **Endpoint:** `GET /desks/:id` Returns one desk in the same shape as the list entry, including its booking calendar. --- ## Meeting Operations ### Create Meeting Create a booking for a meeting room **or** a desk. **Endpoint:** `POST /meetings` **Request Headers:** ```http Authorization: Bearer {access_token} Content-Type: application/json ``` **Request Body (room booking):** ```json { "meetingRoomId": "60d5ec49f1b2c72b8c8e4a1b", "title": "Product Strategy Meeting", "body": "Discussing Q3 product roadmap and priorities", "startDateTime": "2026-07-15T14:00:00+07:00", "endDateTime": "2026-07-15T15:30:00+07:00", "attendees": [ { "email": "john.doe@company.com", "type": "required" }, { "email": "jane.smith@company.com", "type": "optional" } ], "estimatedAttendance": 5, "paymentChannel": "prompt-pay" } ``` **Request Body (desk booking):** Desk bookings specify the time as **exactly one** of `endDateTime` (explicit window) or `timePeriod` (preset block) — never both: ```json { "deskId": "60d5ec49f1b2c72b8c8e4b10", "title": "Work from HQ", "startDateTime": "2026-07-15T09:00:00+07:00", "endDateTime": "2026-07-15T12:30:00+07:00" } ``` ```json { "deskId": "60d5ec49f1b2c72b8c8e4b10", "title": "Work from HQ", "startDateTime": "2026-07-15T00:00:00+07:00", "timePeriod": "morning" } ``` **Request Body Fields:** | Field | Type | Required | Description | | --------------------- | ------- | -------- | ---------------------------------------------------------------------------------------- | | `meetingRoomId` | string | Yes\* | Meeting room to book (\*exactly one of `meetingRoomId` / `deskId`) | | `deskId` | string | Yes\* | Desk to book | | `title` | string | Yes | Booking title | | `body` | string | No | Description / agenda | | `startDateTime` | string | Yes | Start (ISO 8601 with timezone offset) | | `endDateTime` | string | Yes\*\* | End (\*\*desk bookings: provide exactly one of `endDateTime` / `timePeriod`) | | `timePeriod` | string | No | Desk bookings only: `morning`, `afternoon`, `allDay` — mutually exclusive with `endDateTime` | | `attendees` | array | No | Attendee list | | `attendees[].email` | string | Yes | Attendee email | | `attendees[].id` | string | No | Keyspace user ID, if known | | `attendees[].type` | string | No | `required` or `optional` (default `required`) | | `estimatedAttendance` | number | No | Expected participant count | | `paymentChannel` | string | No | `prompt-pay` (default), `credit-card`, `alipay`, `true-money`, `we-chat` | | `discountCode` | string | No | Discount / promo code | | `organizerId` | string | No | Book on behalf of this member (back office). Caller needs `MEETING_UPDATE` on the asset's project (`403` otherwise); the member must belong to it (`400`). The member becomes the organizer, the caller is recorded as `bookedByUserId`, nothing is minted at create | | `paymentDueMinutes` | number | No | Back-office bookings only (`400` otherwise): minutes the organizer has to pay. Absent ⇒ the project's `backofficePaymentDueMinutes`, else 1440. Over 10080 or past the meeting start is clamped, never rejected | | `frontendRedirectUrl` | string | No | URL the payer is redirected to after completing web payment (use your app's return/deep-link URL) | | `draft` | boolean | No | `true` = price preview only, nothing is created (default `false`) | | `draftEndDateTimes` | array | No | With `draft: true`: preview prices for several candidate end times in one call | **Response (Success — 201 Created):** _Free booking (no charge):_ ```json { "code": "KS000", "success": true, "message": "Success", "data": { "_id": "60d5ec49f1b2c72b8c8e4a50", "meetingRoomId": "60d5ec49f1b2c72b8c8e4a1b", "meetingRoomName": "Conference Room A", "title": "Product Strategy Meeting", "body": "Discussing Q3 product roadmap", "status": "active", "startDateTime": "2026-07-15T14:00:00+07:00", "endDateTime": "2026-07-15T15:30:00+07:00", "pinCode": "123456", "organizer": { "userId": "60d5ec49f1b2c72b8c8e4a2a", "email": "organizer@company.com", "name": "John Organizer", "displayName": "John Organizer" }, "attendees": [ { "userId": "60d5ec49f1b2c72b8c8e4a2b", "email": "john.doe@company.com", "name": "John Doe", "type": "required", "responseStatus": "none" } ], "createdAt": "2026-07-10T10:00:00.000Z", "updatedAt": "2026-07-10T10:00:00.000Z" } } ``` _Paid booking (charge applies):_ ```json { "code": "KS000", "success": true, "message": "Success", "data": { "_id": "60d5ec49f1b2c72b8c8e4a50", "meetingRoomId": "60d5ec49f1b2c72b8c8e4a1b", "title": "Product Strategy Meeting", "status": "unpaid", "startDateTime": "2026-07-15T14:00:00+07:00", "endDateTime": "2026-07-15T15:30:00+07:00", "pinCode": "123456", "organizer": { "userId": "60d5ec49f1b2c72b8c8e4a2a", "email": "organizer@company.com", "name": "John Organizer" }, "payment": { "paymentId": "pay_abc123xyz", "invoiceNo": "INV-2026-001234", "amount": 500, "summary": { "subtotal": 500, "total": 450, "discounts": [ { "code": "EARLY10", "value": "50", "name": "Early Bird Discount" } ] }, "webPaymentUrl": "https://payment.example.com/pay/abc123xyz", "webPaymentQr": "data:image/png;base64,iVBORw0KGgoAAAANS..." }, "billingSummaries": [ { "payment_id": "pay_abc123xyz", "amount": 500, "total": 450, "invoice_no": "INV-2026-001234", "status": "unpaid", "expiredAt": "2026-07-10T10:05:00.000Z" } ], "createdAt": "2026-07-10T10:00:00.000Z" } } ``` **Important Notes:** - Booking must satisfy the room's `config`: within operating hours (`dayConfig`), meet `minimumLeadTime`, start within `maxAdvanceBookingDays`, not exceed `maxDurationMinutes`, and avoid `breakTimes`. - A `status: "unpaid"` response means payment is required. Present `payment.webPaymentUrl` (or the `webPaymentQr` image) to the end user — payment is a human web flow, do not automate it. The payment window is **5 minutes** (`billingSummaries[].expiredAt`); an unpaid booking auto-cancels when it lapses. A **back-office booking** (created with `organizerId` by a caller holding `MEETING_UPDATE`) mints no payment at create: the response has no `webPaymentUrl`, `billingSummaries[0]` is a `kind: "quote"` entry without `payment_id`, and the booking is held until `paymentDueAt` (`paymentDueMinutes` → the project's `backofficePaymentDueMinutes` → 1440 minutes, clamped to 7 days and to the meeting start) — the organizer pays it later through the Keyspace app, which appends the real payment entry. - Set `frontendRedirectUrl` so the payer lands back in your application after paying. - Capture and store `pinCode` from the response. It is required later for update/cancel/check-in/check-out when the caller is not the organizer. Treat it as a secret — never log it. - **No-show auto-cancel:** unless disabled for the asset, an `active` booking that nobody checks in to is automatically cancelled ~15 minutes after its start time (see [Notifications & Automatic Status Transitions](#notifications--automatic-status-transitions)). Make sure the user checks in, and re-sync your copy of the booking around start time. - A slot conflict returns `409` with code `KS012` — expected and recoverable; offer another slot. - **Cleaning time counts as taken.** When the room has `cleanupMinutes` (or the property reserves cleaning time for its rooms and desks), a booking may not start before the previous booking's end plus the cleaning, and must end that long before the next booking starts. Such a booking is refused with the same `409` code an overlapping booking gets; the message says when the cleaning ends — "The meeting room is being cleaned until 11:15. Start at 11:15 or later." — or how early to end — "The meeting room needs 15 minutes of cleaning before the next booking at 10:00. End by 09:45 or earlier." Offer the next free start. - **A `409` states why it refused, and the reason is actionable.** The refusal carries its own code: the room is unavailable (`MEETING_006`), the desk is unavailable (`MEETING_044`), the start has passed (`MEETING_045`), the organizer is at their booking quota (`MEETING_046`), the booking is longer than the space allows (`MEETING_047`), the date is a holiday (`MEETING_048`), or a recurring series cannot hold the space (`MEETING_050`). Route on the code, show the message — do not assume a `409` means "try another room", because for most of these it does not. - **A `403` with `MEETING_049` means the organizer is blocked from booking.** An admin has blacklisted them. It is an authorization refusal, not a slot or room problem, so it never belongs in your "offer another slot" branch: show the message and direct the user to the building admin — no other room, time or retry will succeed until the block is lifted. - **A `403` with `KS003` on create means the room or desk is private** (`isPublic: false`) and the organizer is not a member of its project. Private assets are for the project's own people. Offer a public room or desk, or ask the building admin to add the user to the project. - **A `500` with `KS004` is not a booking verdict.** It means an internal fault (dropped connection, write conflict, bug); the room, desk, time and quota were all fine. Retry with backoff per the [error code reference](#error-code-reference) rather than steering the user to a different space. Because there is no idempotency key, reconcile with [`GET /meetings`](#list-meetings) before retrying — see the next note. - There is no idempotency mechanism: **do not blind-retry a timed-out create** — you may double-book. Query [`GET /meetings`](#list-meetings) to reconcile instead. --- ### List Meetings Query bookings — power "my bookings" screens, day timelines, and reconciliation. **Endpoint:** `GET /meetings` **Query Parameters (most used):** | Parameter | Type | Required | Description | | ----------------- | -------- | -------- | ------------------------------------------------------- | | `from` | string | **Yes** | Window start (ISO 8601 with offset) | | `to` | string | **Yes** | Window end | | `projectId` | string | No | Filter by project | | `meetingRoomId` | string | No | Filter by room (also `meetingRoomIds` for multiple) | | `deskId` | string | No | Filter by desk (also `deskIds`) | | `organizerId` | string | No | Filter by organizer user ID | | `organizerEmails` | string[] | No | Filter by organizer email(s) | | `attendeeEmails` | string[] | No | Filter by attendee email(s) | | `statuses` | string[] | No | `active`, `unpaid`, `checked-in`, `done`, `cancelled`, `rejected` | | `assetType` | string | No | Restrict to room or desk bookings | | `limit` / `offset`| number | No | Pagination (max 3000 / default 0) | **Response (Success — 200 OK):** paginated list of meeting objects (same shape as [Get Meeting Detail](#get-meeting-detail)), with `meta`. **Recurring series — read this if you consume `from`/`to`.** A recurring booking is stored as ONE record (the series master) that carries its occurrences internally. When you pass `from`/`to`, this endpoint **expands** the series for you: you get one row per occurrence that overlaps your window, each with that occurrence's real `startDateTime`/`endDateTime` (never truncated to your window), plus two extra fields: | Field | Meaning | | ------------------- | -------------------------------------------------------------------- | | `seriesMasterId` | Present ONLY on an expanded occurrence. Equals the series master `_id` | | `occurrenceEventId` | Present ONLY on an expanded occurrence. Stable + unique per occurrence | Three consequences worth designing around: - **`_id` is NOT unique in the response.** Every occurrence of one series repeats the master's `_id`. De-duplicate and key your lists on `occurrenceEventId` when it is present, falling back to `_id`. - **`_id` is still the right id to ACT on.** Check-in, cancel and `GET /meetings/:id` all take the master `_id` — the expansion is a read-side convenience, not a separate resource. - **`GET /meetings/:id` returns the MASTER, not an occurrence.** Its `startDateTime`/`endDateTime` describe the *current* occurrence and roll forward as the series progresses, so a detail fetch will not match the occurrence row you clicked. If you need a specific occurrence's window, keep it from the list response. Ordinary (non-recurring) bookings are unchanged and carry neither field. **cURL Example — today's bookings for one room:** ```bash curl -G "https://api.keyspace-qat.com/meetings" \ -H "Authorization: Bearer {access_token}" \ --data-urlencode "from=2026-07-15T00:00:00+07:00" \ --data-urlencode "to=2026-07-16T00:00:00+07:00" \ --data-urlencode "meetingRoomId=60d5ec49f1b2c72b8c8e4a1b" ``` --- ### Get Meeting Detail **Endpoint:** `GET /meetings/:id` Returns one booking: status, times, room/desk, organizer, attendees, `pinCode`, payment/billing summaries (for paid bookings), and `histories` (create/update/cancel/check-in/check-out/payment events). Use this to confirm payment completion after directing a user to `webPaymentUrl` (poll until `status` leaves `unpaid`), and to re-sync state after errors or timeouts. --- ### Update/Extend Meeting **Endpoint:** `PATCH /meetings/:id` **Request Body Fields (all optional unless noted):** | Field | Type | Description | | --------------------- | ------- | -------------------------------------------------------------- | | `title` | string | Updated title | | `body` | string | Updated description | | `startDateTime` | string | New start (cannot change after check-in) | | `endDateTime` | string | New end — extend or shorten | | `timePeriod` | string | Desk bookings: `morning` / `afternoon` / `allDay` | | `attendees` | array | Replacement attendee list | | `estimatedAttendance` | number | Updated participant count | | `pinCode` | string | Required when the caller is not the organizer | | `paymentChannel` | string | Payment channel if the change requires payment (same values as create) | | `discountCode` | string | Promo code for the additional charge | | `frontendRedirectUrl` | string | Post-payment redirect for the additional charge | | `draft` | boolean | `true` = preview the price of the change without applying it | | `draftEndDateTimes` | array | With `draft`: preview several candidate end times | **Response (Success — 200 OK):** the updated meeting. If the change requires additional payment (e.g. extending a paid booking), the response carries `status: "unpaid"` plus a `payment` object — same handling as a paid create. **Important Notes:** - Only `active` or `checked-in` bookings can be updated. - Start time cannot change once checked in. - The change must not conflict with other bookings (`409 KS012`). - That includes the cleaning time (see [Meeting Rooms](#meeting-rooms)): a move or an extension that runs into it returns the same `409 KS012`, with a message that says when the cleaning ends or how early to end. An edit that keeps the time (title, body, attendees) is never refused for cleaning time, and neither is a booking that sat next to another before the cleaning time was set — unless the change moves it closer. `draftInfo[].isAvailable` on a `draft: true` update already accounts for the cleaning time. - A change to `startDateTime` or `endDateTime` by an organizer an admin has blocked from booking returns `403` `MEETING_049`, the same refusal as create. An edit that keeps the time (title, body, attendees) still succeeds. --- ### Cancel Meeting **Endpoint:** `POST /meetings/:id/cancel` **Request Body Fields (optional):** | Field | Type | Description | | ----------- | ------ | ------------------------------------------------ | | `pinCode` | string | Required when the caller is not the organizer | | `paymentId` | string | Payment to void when cancelling an unpaid booking | **Response (Success — 202 Accepted):** the meeting with `status: "cancelled"` and an appended `histories` entry. **Important Notes:** - Only `active` and `unpaid` bookings can be cancelled. - A **`checked-in` booking cannot be cancelled** — end it with [check-out](#check-out-from-meeting) instead (organizer or `pinCode`). - Bookings that already ended (`done`) cannot be cancelled. - Cancellation is authorized for the organizer, a caller presenting the booking's `pinCode`, or a project admin. - Pending payments are voided automatically. - Cancellation is irreversible. --- ### Check-In to Meeting **Endpoint:** `POST /meetings/:id/check-in` **Request Body Fields (optional):** | Field | Type | Description | | --------- | ------ | ------------------------------------------------ | | `pinCode` | string | Required when the caller is not the organizer | **Response (Success — 202 Accepted):** the meeting with `status: "checked-in"` and per-person `checkInDateTime`. **Important Notes:** - Check-in opens **15 minutes before start** by default (configurable per room). - Only `active` bookings can check in — not `unpaid`, `cancelled`, `rejected`, or `done`. Paid bookings must complete payment first. - Multiple attendees can check in independently. --- ### Check-Out from Meeting **Endpoint:** `POST /meetings/:id/check-out` **Request Body Fields (optional):** | Field | Type | Description | | --------- | ------ | ------------------------------------------------ | | `pinCode` | string | Required when the caller is not the organizer | **Response (Success — 202 Accepted):** the meeting with `status: "done"` and `checkOutDateTime` set. **Important Notes:** - Only `checked-in` bookings can check out. - Check-out is the **only** way to end a `checked-in` booking — cancellation is no longer possible after check-in. - Check-out ends the booking and releases the room. It cannot be undone. --- ## Meeting Lifecycle ``` create (free room) ─────────────────────────────────────────► active create (paid room) ──► unpaid ── payment completed ──────────► active unpaid ── cancel, or payment window lapses (~5 min) ─────► cancelled active ── cancel (organizer / pinCode / admin) ──────────► cancelled active ── no-show auto-cancel (~15 min after start) ─────► cancelled active ── check-in (opens ~15 min before start) ─────────► checked-in checked-in ── check-out (organizer / pinCode) ───────────────► done active / checked-in ── end of booking (automatic) ────────────► done A checked-in booking can NOT be cancelled — it ends only via check-out or automatically at the booking's end time. rejected — the booking was declined by the room's calendar system or an administrator; terminal, like cancelled. ``` | Status | Meaning | Leaves this state via | | ------------ | ------------------------------------------------------------ | ------------------------------------------------------------------ | | `unpaid` | Created, awaiting payment | payment → `active`; cancel or ~5-min payment lapse → `cancelled` | | `active` | Confirmed | check-in → `checked-in`; cancel or no-show auto-cancel → `cancelled`; end time → `done` | | `checked-in` | Attendee(s) present | check-out or end time → `done` — **cancel not allowed** | | `done` | Completed (checked out or ended) | terminal | | `cancelled` | Cancelled by user or system (payment lapse / no-show) | terminal | | `rejected` | Declined by the room's calendar provider or an administrator | terminal | --- ## Notifications & Automatic Status Transitions Keyspace runs a **notification scheduler** for every booking. Some ticks only notify the participants (through the Keyspace apps — push, LINE, email); two of them **change the booking's status automatically**. Your integration must expect these transitions, because they happen without any API call from you. ### The scheduler ticks | Event | When (default) | Effect | | -------------------- | ------------------------------ | ------------------------------------------------------------------ | | `create` | on booking confirmation | notification only | | `update` | on booking change | notification only | | `cancel` | on cancellation | notification only | | `pre-start` | **15 min before start** | reminder to participants | | `post-start-warn` | **10 min after start** | warning: check in now or the booking will be auto-cancelled | | `post-start-expire` | **15 min after start** | **AUTO-CANCEL** — an `active` booking nobody checked in to flips to `cancelled` | | `pre-end` | **15 min before end** | ending-soon reminder (extend via `PATCH /meetings/:id` if needed) | | *(unpaid timeout)* | **~5 min after create (paid)** | **AUTO-CANCEL** — an `unpaid` booking whose payment window lapsed flips to `cancelled` | At the booking's end time the meeting is closed automatically (`active`/`checked-in` → `done`); no notification is sent for that. ### Per-asset timing configuration The four timed ticks are configurable per room/desk and exposed to you in the asset's `config.meetingConfig`: ```json "config": { "meetingConfig": { "preStartMinutes": 15, "postStartWarnMinutes": 10, "postStartExpireMinutes": 15, "preEndMinutes": 15 } } ``` - A value of **`-1` disables** that tick. `postStartExpireMinutes: -1` disables the no-show auto-cancel entirely for that asset. - When `meetingConfig` is absent, the defaults above apply. - Read these from `GET /meeting-rooms` / `GET /desks` if you want to show accurate countdowns in your own UI. ### What this means for your integration - **Statuses change without you.** A booking you created can become `cancelled` (no-show, payment lapse) or `done` (end of booking) with no call from your side. Re-fetch (`GET /meetings/:id`) around start time and before showing state-dependent actions. - **Check-in matters.** If your users book through your app but never check in, their bookings evaporate ~15 minutes in. Surface the check-in action prominently, or agree with Keyspace to configure `postStartExpireMinutes: -1` on your assets. - **Delivery to your server:** register a webhook listener and Keyspace POSTs these lifecycle events to your backend as they happen — see [Receiving Events — Webhooks](#receiving-events--webhooks). Polling `GET /meetings` remains the reconciliation fallback. --- ## Receiving Events — Webhooks Keyspace can POST booking **and visitor** lifecycle events to a **listener server you host**, so your backend learns about state changes (including the automatic ones above) without polling. The same channel, signature, and retry policy cover both booking (`ROOM_`/`DESK_`) and visitor (`VMS_`) events — you subscribe to whichever `type`s you need. ### Registration Registration is managed by Keyspace during onboarding. Give your Keyspace contact: - your **HTTPS callback URL** (plain `http://` is not accepted; use a tunnel for development testing) - a **shared secret** you generate (32–256 characters) — used to HMAC-sign every delivery; store it like a password, rotate it via your Keyspace contact if it leaks - the **event types** you want (see catalog below) Webhook delivery is enabled per environment — confirm with your contact that it is switched on for the environment you integrate against. ### The delivery request Each event is an HTTP `POST` to your callback URL: ```http POST /your/callback/path HTTP/1.1 Content-Type: application/json User-Agent: KeyspaceWebhooks/1.0 X-Keyspace-Event: ROOM_CANCEL X-Keyspace-Delivery: whk_7f8a1f4e-1234-4b3c-9d2e-abcdef012345 X-Keyspace-Timestamp: 1752562800000 X-Keyspace-Signature: sha256=3f5c…e2a1 ``` ```json { "id": "whk_7f8a1f4e-1234-4b3c-9d2e-abcdef012345", "type": "ROOM_CANCEL", "version": 1, "occurredAt": "2026-07-15T07:15:00.000Z", "projectId": "60d5ec49f1b2c72b8c8e4a1c", "data": { "roomName": "Conference Room A", "floorName": "Floor 5", "bookingDate": "2026-07-15T07:00:00.000Z", "cancelReason": "No-show — auto-cancelled by system", "userName": "Somchai", "userEmail": "somchai@example.com" } } ``` - `id` (= `X-Keyspace-Delivery`) is unique **per delivery** — use it to de-duplicate retries on your side. - `version` is the envelope version (currently `1`); breaking envelope changes bump it. - `data` is a change **signal**, not the full record — for booking events fetch authoritative state with `GET /meetings/:id` when you need it. Tolerate unknown fields: `data` may gain fields without a version bump. It never contains a meeting `pinCode`, access tokens, or payment URLs. **Exception:** `VMS_INVITE_SENT` deliberately carries `qrCardCode`, the visitor's building-access QR, because the partner is expected to relay it to the invitee — treat it as a credential (TLS only, do not log it, do not expose it beyond the intended visitor). Visitor (`VMS_`) events have no partner `GET` endpoint; they are terminal signals. ### Verifying the signature (required) The signature is `sha256=HEX( HMAC-SHA256( secret, timestamp + "." + rawBody ) )` over the **exact raw request bytes** — verify before trusting the payload, and reject stale timestamps to block replays: ```javascript const crypto = require("node:crypto"); function verifyKeyspaceWebhook(headers, rawBody, secret) { const timestamp = headers["x-keyspace-timestamp"]; const received = headers["x-keyspace-signature"] ?? ""; const expected = "sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"); const fresh = Math.abs(Date.now() - Number(timestamp)) < 5 * 60 * 1000; return ( fresh && received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)) ); } ``` > Compute the HMAC over the **raw body bytes** (before any JSON parsing) — a > re-serialized body will not match. ### Authenticating to your endpoint Every delivery is **always** HMAC-signed (previous section) — that proves it came from Keyspace. Separately, if **your** endpoint sits behind its own auth, tell your Keyspace contact which scheme to use and Keyspace will add the matching header on top of the signature: | Scheme | Header Keyspace adds | You provide | | --- | --- | --- | | `signature-only` (default) | *(none — verify the signature)* | nothing | | `bearer` | `Authorization: Bearer ` | a static token | | `custom-header` | one or more headers, each `: ` (e.g. `X-Api-Key` + `X-Client-Id`) | a list of header name/value pairs | | `jwt` | `Authorization: Bearer ` | nothing — Keyspace mints a short-lived HS256 JWT signed with your shared secret | For **`jwt`**: each delivery carries a fresh token (~60 s TTL) with claims `iss: "keyspace"`, `aud: `, `jti: `, `exp`. Verify it with HS256 and the same shared secret you use for the signature: ```javascript const jwt = require("jsonwebtoken"); const claims = jwt.verify(bearerToken, sharedSecret, { algorithms: ["HS256"], issuer: "keyspace", audience: yourProjectId, }); ``` Pick `signature-only` unless your gateway genuinely rejects unauthenticated POSTs — verifying the signature is simpler and needs nothing from us. ### Responding, retries, and the failure policy - Respond **2xx within 10 seconds** to acknowledge. Do heavy work async — ack first, process after. - Anything else (non-2xx, timeout, connection error) is retried up to **5 attempts** with exponential backoff starting at ~1 minute. - After **20 consecutive deliveries that exhaust all retries**, the webhook is **auto-disabled** to protect the queue — ask your Keyspace contact to re-enable it once your endpoint is healthy (re-enabling resets the counter). - Ordering is not guaranteed under retries — use `occurredAt` (and re-fetching) rather than arrival order. ### Event catalog | `type` | Fires | Key `data` fields | | --- | --- | --- | | `ROOM_BOOK` / `DESK_BOOK` | booking confirmed | `roomName`/`deskName`, `bookingDate`, `startTime`, `endTime`, `userName`, `userEmail` | | `ROOM_MODIFY` / `DESK_MODIFY` | booking changed | `oldStartTime`, `oldEndTime`, `newStartTime`, `newEndTime` | | `ROOM_CANCEL` / `DESK_CANCEL` | booking cancelled — user action **or** system auto-cancel | `bookingDate`, `cancelReason` (absent = user/admin cancel; `"No-show — auto-cancelled by system"`; `"Payment window lapsed — auto-cancelled by system"`; `"Unpaid — back-office hold expired"`) | | `ROOM_REMINDER` / `DESK_REMINDER` | ~15 min before start | `startTime`, `endTime`, `minutesUntilStart` | | `ROOM_NO_SHOW_WARNING` / `DESK_NO_SHOW_WARNING` | ~10 min after start, nobody checked in | `startTime`, `endTime`, `minutesUntilAutoCancel` | | `ROOM_ENDING_SOON` / `DESK_ENDING_SOON` | ~15 min before end | `endTime`, `minutesUntilEnd` | | `ROOM_PAYMENT_DUE` / `DESK_PAYMENT_DUE` | a back-office booking (`bookedVia: "backoffice"`) is held unpaid — sent at create and once more at ~75 % of the hold | `bookingDate`, `startTime`, `endTime`, `amount`, `currency`, `paymentDueAt`, `paymentDueTime`, `minutesUntilDue` | Timings follow the asset's `config.meetingConfig` (see the previous section) — the ~values above are the defaults. #### Visitor events (VMS) Delivered on the same channel for projects that use the Visitor Management System. Subscribe to these `type`s the same way you subscribe to booking events. | `type` | Fires | Key `data` fields | | --- | --- | --- | | `VMS_INVITE_SENT` | a visitor invite is issued | `visitorName`, `visitorEmail`, `hostName`, `hostEmail`, `visitDate`, `startDateTime`, `endDateTime` (`null` = no end time), `qrCardCode` (**access QR — relay to the visitor, treat as a credential**), `licensePlate?`, `visitPurpose?` | | `VMS_VISITOR_CHECKED_IN` | a visitor checks in (card or license plate) | `visitorName`, `hostAssetTitle`, `checkInTime` (`{ en, th }`), `vmsEntryType`, `licensePlateNumber`, `location?`, `webViewUrl?` | | `VMS_HOST_CANCELLED` | the host cancels the visit | `visitorName`, `hostName`, `hostEmail`, `startDateTime`, `endDateTime` (`null` = no end time), `licensePlate?` | Only these three visitor `type`s emit today. (`VMS_VISITOR_CHECKED_OUT` and `VMS_HOST_NOTIFICATION` are reserved in the schema but not yet emitted — subscribing to them delivers nothing.) ### Per-event payload reference Exact `data` fields per event `type`, sourced from the emitting code. Rules that apply to every event: - **Base fields on every event** — `data` always carries `projectId` (string, hex id — mirrors the envelope), `userId` (string, hex id — the acting Keyspace user: booking organizer / VMS transactor), and `timestamp` (ISO 8601 — mirrors the envelope's `occurredAt`). - **Formats** — `bookingDate` / `visitDate` are ISO 8601 date-times. `startTime` / `endTime` / `oldStartTime` / `newStartTime` (etc.) on **booking** events are property-local wall-clock **`HH:mm`** strings. VMS `startDateTime` / `endDateTime` are preformatted display strings. - **Optionality** — fields marked `?` may be absent. Tolerate absent AND unknown fields; `data` may gain fields without a version bump. - **Room vs desk** — `ROOM_*` and `DESK_*` payloads are identical except the asset field: `roomName` on rooms, `deskName` on desks. Documented once below with `roomName|deskName`. #### `ROOM_BOOK` / `DESK_BOOK` — booking confirmed | Field | Type | Notes | | --- | --- | --- | | `roomName` \| `deskName` | string | booked asset | | `floorName` | string | asset's floor | | `bookingDate` | ISO 8601 | booking start instant | | `startTime`, `endTime` | `HH:mm` | property-local wall-clock | | `attendees?` | string[] | attendee display names | | `userName?`, `userEmail?` | string | organizer | ```json { "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" } ``` #### `ROOM_MODIFY` / `DESK_MODIFY` — booking changed | Field | Type | Notes | | --- | --- | --- | | `roomName` \| `deskName`, `floorName` | string | | | `oldStartTime`, `oldEndTime` | `HH:mm` | window before the change | | `newStartTime`, `newEndTime` | `HH:mm` | window after the change | | `userName?` | string | who changed it | #### `ROOM_CANCEL` / `DESK_CANCEL` — booking cancelled | Field | Type | Notes | | --- | --- | --- | | `roomName` \| `deskName`, `floorName` | string | | | `bookingDate` | ISO 8601 | | | `cancelReason?` | string | **absent** = user/admin cancel; `"No-show — auto-cancelled by system"`; `"Payment window lapsed — auto-cancelled by system"`; `"Unpaid — back-office hold expired"` | | `userName?` | string | | ```json { "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" } ``` #### `ROOM_REMINDER` / `DESK_REMINDER` — ~15 min before start | Field | Type | Notes | | --- | --- | --- | | `roomName` \| `deskName`, `floorName` | string | | | `startTime`, `endTime` | `HH:mm` | | | `minutesUntilStart` | number | per-asset `preStartMinutes` (default 15) | | `userName?`, `userEmail?` | string | | #### `ROOM_NO_SHOW_WARNING` / `DESK_NO_SHOW_WARNING` — no check-in, auto-cancel imminent | Field | Type | Notes | | --- | --- | --- | | `roomName` \| `deskName`, `floorName` | string | | | `startTime`, `endTime` | `HH:mm` | | | `minutesUntilAutoCancel` | number | time left to check in before the no-show cancel | | `userName?` | string | | #### `ROOM_ENDING_SOON` / `DESK_ENDING_SOON` — ~15 min before end | Field | Type | Notes | | --- | --- | --- | | `roomName` \| `deskName`, `floorName` | string | | | `endTime` | `HH:mm` | | | `minutesUntilEnd` | number | per-asset `preEndMinutes` (default 15) | | `userName?` | string | | #### `ROOM_PAYMENT_DUE` / `DESK_PAYMENT_DUE` — back-office booking awaiting the resident's payment Only for `bookedVia: "backoffice"` bookings (staff booked FOR the resident; the resident pays from their own device). Sent at create and once more at ~75 % of the hold; when the hold lapses the booking is cancelled with `cancelReason: "Unpaid — back-office hold expired"`. | Field | Type | Notes | | --- | --- | --- | | `roomName` \| `deskName`, `floorName` | string | | | `bookingDate` | ISO 8601 | | | `startTime`, `endTime` | `HH:mm` | | | `amount` | number | the amount still due (the staff quote until the resident mints a payment) | | `currency` | string | `THB` | | `paymentDueAt` | ISO 8601 | the hold deadline — same instant as `GET /meetings/{id}` `paymentDueAt` | | `paymentDueTime` | string | `paymentDueAt` pre-formatted (`dd MMM yyyy HH:mm`) | | `minutesUntilDue` | number | at send time | | `userName?`, `userEmail?` | string | the resident | #### `VMS_INVITE_SENT` — visitor invite issued | Field | Type | Notes | | --- | --- | --- | | `visitorName`, `visitorEmail` | string | the invitee | | `hostName`, `hostEmail` | string | the host | | `visitDate` | ISO 8601 | | | `startDateTime` | string | preformatted display string (site local time) | | `endDateTime` | string \| null | preformatted display string; **`null` when the invite has no end time**: the visitor may arrive any time on the start day, and the invite expires at the end of that day | | `qrCardCode` | string | **the visitor's building-access QR — relay to the invitee; treat as a credential** (TLS only, never log) | | `visitPurpose?` | string | | | `licensePlate?` | string | | ```json { "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", "licensePlate": "กก 1234" } ``` An invite with no end time carries `"endDateTime": null`. Read it as "valid all day on the start date": treat a missing end as the end of that day. Do not reject the event. #### `VMS_VISITOR_CHECKED_IN` — visitor checked in (card or license plate) | Field | Type | Notes | | --- | --- | --- | | `visitorName` | string | | | `hostAssetTitle` | string | the host's unit/asset | | `checkInTime` | `{ en, th }` | localized display strings | | `vmsEntryType` | `{ entryType, en, th }` | how they entered (card / plate) | | `licensePlateNumber` | string | empty when card entry | | `location?` | `{ entryType, en, th }` | entry point, when known | | `webViewUrl?` | string | Keyspace visit web view | #### `VMS_HOST_CANCELLED` — host cancelled the visit | Field | Type | Notes | | --- | --- | --- | | `visitorName`, `hostName`, `hostEmail` | string | | | `startDateTime` | string | preformatted display string (site local time) | | `endDateTime` | string \| null | preformatted display string; **`null` when the invite had no end time** | | `licensePlate?` | string | | --- ## Error Handling ### Error envelope ```json { "code": "KS012", "message": "Reservation time slot conflict", "success": false } ``` Branch on the HTTP status plus `code`. The `message` text is human-readable and **not** a stable contract. ### Validation errors (400) Request-validation failures return `KS001` with `message` as an **array of strings**, one per violated constraint: ```json { "code": "KS001", "message": [ "title should not be empty", "startDateTime must be a valid ISO 8601 date string" ], "success": false } ``` ### Error code reference | HTTP | Code | Meaning | Handling | | ---- | ------- | ------------------------------------ | ------------------------------------------------------------ | | 400 | `KS001` | Bad request / validation failed | Fix the request; surface `message[]` details | | 401 | `KS002` | Unauthorized (missing/expired token) | Refresh or re-authenticate, retry once | | 403 | `KS003` | Forbidden (no access to resource) | Check the project/room your credentials are scoped to | | 404 | `KS005` | Resource not found | Verify IDs | | 409 / 410 | `KS006` | State conflict (generic) | The request contradicts current resource state. Same code can arrive as `410 Gone` when the target no longer exists (e.g. already-cancelled) — branch on the HTTP status, not the code alone | | 409 | `KS012` | **Reservation time-slot conflict** | Expected on create/update races, or when the booking runs into the cleaning time — offer another slot | | 429 | `KS036` | Too many requests | Back off and retry with exponential delay | | 403 | `KS221` | act-as / partner members: app not authorized for this subject | Only act as / manage your OWN linked members, within your project scope; `external_user_id` routing needs a single-project app | | 401 | `KS223` | act-as / partner members: subject could not be resolved | Assert a valid `subject`/`subject_type` (act-as); on `/partner/members/*` the member row is missing — contact Keyspace | | 500 / 503 | `KS004` | Internal / upstream error | Retry with backoff (max 3); report if persistent. `503` signals a transient upstream dependency — the same retry strategy applies | | 503 | `KS234` | Authentication temporarily unavailable (the token could not be checked — it was not rejected) | Retry with backoff; do not re-authenticate or discard tokens | > **A `KSxxx` code is not 1:1 with an HTTP status.** `KS006` spans `409`/`410` > and `KS004` spans `500`/`503`. Switch on the HTTP status for control flow and > use the `code` for logging/telemetry. Other `KSxxx` codes exist for specific business rules; handle unknown codes generically by HTTP status. --- ## Best Practices ### 1. Token management - One token per server process/pool — do not authenticate per request. - Compute expiry from `expires_in`; refresh ~5 minutes early. - On `401 KS002`: refresh → retry once → full re-auth → propagate. - Keep `app_id`/`app_secret` server-side only. **Never ship them in a mobile app or web bundle.** ### 2. Retries and idempotency - Retry **only** network failures and `5xx`, with exponential backoff, max 3 attempts. - Never auto-retry `4xx`. - `POST /meetings` has **no idempotency key**: after a timeout, do not resend blindly. Reconcile via `GET /meetings` (filter by room and window) to see whether the booking was created. ### 3. Booking flow - Use `GET /meeting-rooms?startDateTime=…&endDateTime=…` for availability-driven UX rather than trial-and-error creates. - Validate against room `config` (`dayConfig`, `minimumLeadTime`, `maxAdvanceBookingDays`) client-side to fail fast — the server enforces them anyway. - Use `draft: true` to preview pricing before committing; `draftEndDateTimes` prices several durations in one call. - Treat `409 KS012` as a normal, recoverable outcome of concurrent booking. - **Reconcile around the automatic transitions**: re-fetch a booking near its start time (no-show auto-cancel), after sending a user to payment (unpaid lapse), and at its end time (`done`) — statuses change without your API calls. ### 4. Payments - Present `webPaymentUrl` / `webPaymentQr` to the end user promptly — the window is 5 minutes. - Pass `frontendRedirectUrl` to bring the user back into your app after payment. - Poll `GET /meetings/:id` to confirm the transition `unpaid → active` before showing success. ### 5. Data hygiene - Store meeting `_id` + `pinCode` (encrypted at rest); never log `pinCode`, tokens, or secrets. - Timezones explicit on every date-time you send; render times to users in the property's local timezone. --- ## Example Integration Workflow ```javascript const BASE_URL = process.env.KEYSPACE_BASE_URL; // e.g. https://api.keyspace-qat.com class KeyspaceClient { constructor(appId, appSecret) { this.appId = appId; this.appSecret = appSecret; this.accessToken = null; this.refreshToken = null; this.expiresAt = 0; } async authenticate() { const res = await fetch(`${BASE_URL}/auth/accessToken`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ app_id: this.appId, app_secret: this.appSecret, grant_type: "app_credentials", }), }); if (!res.ok) throw new Error(`auth failed: ${res.status}`); this.storeTokens(await res.json()); } async refresh() { const res = await fetch(`${BASE_URL}/auth/accessToken`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: this.refreshToken, grant_type: "refresh_token", }), }); if (!res.ok) return this.authenticate(); // refresh expired → full re-auth this.storeTokens(await res.json()); } storeTokens({ access_token, refresh_token, expires_in }) { this.accessToken = access_token; this.refreshToken = refresh_token; this.expiresAt = Date.now() + expires_in * 1000; } async request(path, options = {}) { if (Date.now() >= this.expiresAt - 300_000) await this.refresh(); // 5-min buffer const res = await fetch(`${BASE_URL}${path}`, { ...options, headers: { ...options.headers, Authorization: `Bearer ${this.accessToken}`, }, }); return res; } } const client = new KeyspaceClient( process.env.KEYSPACE_APP_ID, process.env.KEYSPACE_APP_SECRET, ); // 1. Authenticate await client.authenticate(); // 2. Find rooms free for the requested slot const slot = { start: "2026-07-15T10:00:00+07:00", end: "2026-07-15T10:30:00+07:00", }; const roomsRes = await client.request( `/meeting-rooms?projectId=${PROJECT_ID}&status=active` + `&startDateTime=${encodeURIComponent(slot.start)}` + `&endDateTime=${encodeURIComponent(slot.end)}`, ); const rooms = (await roomsRes.json()).data.filter((r) => !r.unavailableReason); // 3. Create the booking const createRes = await client.request("/meetings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ meetingRoomId: rooms[0]._id, title: "Team Standup", startDateTime: slot.start, endDateTime: slot.end, attendees: [{ email: "team@company.com", type: "required" }], frontendRedirectUrl: "https://yourapp.example.com/booking/return", }), }); if (createRes.status === 409) { // KS012 — someone took the slot between search and create; offer another slot throw new Error("slot taken, pick another"); } const meeting = (await createRes.json()).data; // 4. Paid room → hand the payment URL to the user, then poll for confirmation if (meeting.status === "unpaid") { showPaymentPage(meeting.payment.webPaymentUrl); // human flow, 5-minute window await pollUntil(async () => { const d = (await (await client.request(`/meetings/${meeting._id}`)).json()).data; return d.status !== "unpaid"; }); } // 5. On meeting day — check in (opens 15 min before start) await client.request(`/meetings/${meeting._id}/check-in`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pinCode: meeting.pinCode }), }); // 6. Check out when done (irreversible) await client.request(`/meetings/${meeting._id}/check-out`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pinCode: meeting.pinCode }), }); ``` --- ## Changelog ### Version 1.15.0 (2026-10) - **Visitor invites may have no end time** ([§Per-event payload reference](#per-event-payload-reference)): `VMS_INVITE_SENT` and `VMS_HOST_CANCELLED` can now carry `"endDateTime": null`. A visitor invite needs only a start time. Without an end, it is valid for the whole start day and expires at the end of that day. Invites with an end time are unchanged. If your listener requires `endDateTime` to be a string, accept `null`. No other field changes. ### Version 1.14.0 (2026-10) - **New `503` `KS234` — authentication temporarily unavailable** ([§Error code reference](#error-code-reference)) — additive. Every authenticated endpoint, and `POST /auth/accessToken` when a rotated refresh token is presented again, can now answer `503` with `KS234` when the credential cannot be checked right now. The credential was not rejected: retry with backoff and do not re-authenticate or discard your tokens. No existing response changed: a missing, expired or revoked token is still `401` `KS002`. ### Version 1.13.1 (2026-10) - **Desk availability search counts the cleaning time** ([§Desks](#desks)) — the `GET /desks` availability search (`startDateTime` plus `endDateTime` or `timePeriod`) now also leaves out a desk whose window starts inside a booking's cleaning time, or whose own cleaning would run into the next booking, using the property's default cleaning time (desks have no `cleanupMinutes` of their own). Until now a desk could be listed as free and then refused by `POST /meetings` for its cleaning time. Desks without cleaning time — and pooled desks — behave exactly as before. The `GET /meeting-rooms` description in the OpenAPI spec now states the same rule for rooms (behaviour unchanged since 1.13.0). ### Version 1.13.0 (2026-10) - **Cleaning time between bookings** ([§Meeting Rooms](#meeting-rooms), [§Create Meeting](#create-meeting), [§Update/Extend Meeting](#updateextend-meeting)) — a room may reserve minutes of cleaning after every booking: meeting-room reads (`GET /meeting-rooms`, `GET /meeting-rooms/{id}`) may carry `cleanupMinutes`, an integer 0–60 (`0` = none; absent = the property's default, which may be none and also applies to desks). `POST /meetings` and `PATCH /meetings/{id}` now refuse a booking that starts inside the previous booking's cleaning, or whose own cleaning runs into the next booking, with the same `409` code an overlapping booking gets and a message that says when the cleaning ends ("The meeting room is being cleaned until 11:15. Start at 11:15 or later.") or how early to end. An edit that keeps the time is never refused for it, and `draftInfo[].isAvailable` accounts for it. The `GET /meeting-rooms` availability search (`startDateTime` + `endDateTime`) leaves out a room whose cleaning time the window would run into. Rooms and desks without cleaning time behave exactly as before. ### Version 1.12.2 (2026-09) - **Meeting-room outline** ([§Meeting Rooms](#meeting-rooms)) — meeting-room reads (`GET /meeting-rooms`, `GET /meeting-rooms/{id}`) may carry `polygon`: an array of `{ xPct, yPct }` vertices (0–100, percent of the floor-plan image), in drawing order and implicitly closed. Absent, `null` or `[]` means the room has no outline. Additive; existing fields are unchanged. ### Version 1.12.1 (2026-09) - **Private rooms and desks are for their project's people** ([§Create Meeting](#create-meeting)) — `POST /meetings` for a room or desk with `isPublic: false` now returns `403` `KS003` when the organizer is not a member of the asset's project. Nothing is booked and the organizer is not added to the project. The same rule applies to a booking through `sessionId` on such an asset. Public rooms and desks are unchanged: anyone logged in can book them. An app token holding a role on the project, and brand staff, still book private assets. ### Version 1.12.0 (2026-09) - **Book on behalf of a member (back office)** ([§Create Meeting](#create-meeting)) — `POST /meetings` accepts `organizerId`. The caller must hold `MEETING_UPDATE` on the asset's project (`403` otherwise) and the member must belong to it (`400`). The member is the organizer, the caller is recorded as `bookedByUserId` (with `bookedVia` marking the booking as back-office), promo pricing is evaluated in the member's scope, and nothing is minted at create — `billingSummaries[0]` is a `kind: "quote"` entry the member pays later from their own device. - **Payment hold window for back-office bookings** — such a booking is held until the new response field `paymentDueAt`: request `paymentDueMinutes` (back-office only, `400` otherwise) → the project's `backofficePaymentDueMinutes` → 1440 minutes; over 10080 minutes or past the meeting start is clamped, never rejected. `billingSummaries[].expiredAt` equals it and the booking auto-cancels when it lapses. Self-service bookings keep the 5-minute window. ### Version 1.11.3 (2026-09) - **A blocked organizer is now actually refused with `403` `MEETING_049`** on `POST /meetings` and on `PATCH /meetings/:id` when the change moves `startDateTime` or `endDateTime` ([§Update/Extend Meeting](#updateextend-meeting)). 1.11.2 documented the code, but these routes did not yet return it: an organizer an admin had blocked from meeting rooms could still book and reschedule. The refusal is checked before every booking-window rule, so a blocked organizer gets `MEETING_049` even when the slot would also fail another rule. An edit that keeps the time (title, body, attendees) still succeeds, and bookings made before the block are not cancelled. Desk bookings are unaffected. `PATCH /meetings/{id}` now lists the `403` in the OpenAPI spec. If you already route `MEETING_049` to a "contact your building admin" message, nothing changes on your side. ### Version 1.11.2 (2026-09) - **`POST /meetings` now answers `500 KS004` for internal faults** ([§Create Meeting](#create-meeting)) — a dropped database connection, a write conflict, or a bug used to surface as `409` with `MEETING_006` "the meeting is not available (may be due to the meeting room currently being unavailable)". That told you to offer another room, which never helped. These faults now return `500` with `KS004` and follow the documented retry-with-backoff contract. **This fix moved no refusal's status** — the one new refusal that is not a `409` is `MEETING_049`, a `403` (next entry). - **Booking refusals name their real reason** — one `409` used to carry `MEETING_006` for unrelated conditions. A refusal now carries its own code and sentence: `MEETING_044` (desk unavailable — a desk booking is no longer described as a room problem), `MEETING_045` (start already passed), `MEETING_046` (booking quota reached), `MEETING_047` (longer than the space's maximum), `MEETING_048` (closed for a holiday), `MEETING_049` (organizer blocked by an admin — **`403`**, not `409`), `MEETING_050` (recurring series cannot hold the space). `MEETING_006` remains, narrowed to what it says: the room itself is unavailable. Codes are additive and no refusal a released version returned changed status, so existing handling keeps working — but a client that matched on the `MEETING_006` string to detect these cases must now match the new codes. The one new code that is not a `409` is `MEETING_049`, which answers `403`: a blocked organizer is an authorization refusal, not a slot problem, so route it to a "contact your building admin" message rather than to your "offer another slot" branch. ### Version 1.11.1 (2026-09) - **Docs: per-module guides** — `/docs` is now the platform hub (credentials, token, envelope, `act_as`, webhooks) with an index of module guides. The booking narrative moved to `/docs/guides/meeting.md` (`/docs/integration-guide.md` is a 301) and the partner-managed members journey has its own guide at `/docs/guides/members.md`. - **Partner-created members are project members** ([§5](#5-provision-members-and-credentials-partner-managed)) — a member created through `PUT /partner/members/{externalUserId}` is now `userType: member` with provenance `source: partner` (previously it shared the `act_as` guest classification). Subjects created only through `act_as` stay `visitor` (`source: federated`) until an admin promotes them; members you resolve to (email / phone / employeeId match) keep their existing `source`. `GET /partner/members/{externalUserId}` now returns `source` and `userType`. Existing partner-created members were re-stamped server-side; no partner action needed. ### Version 1.11.0 (2026-09) - **Partner members now resolve to EXISTING members** ([§5](#5-provision-members-and-credentials-partner-managed)) — `PUT /partner/members/{externalUserId}` matches, in order, the existing link → `email` → `phone` (normalized) → **new `employeeId`** (`ProjectUser` employee id, unique per project) before creating a member, and adds the project membership when the matched user lacks one. Response gains `matchedBy`, `employeeId`, `unit`. `409 KS006` with `data.conflictKey` (+ `data.linkedExternalUserId`) when a key points at a different member or at one already managed under another `externalUserId` — never a silent re-link. - **New condo `unit` binding** — `unit: { addressNo, zoneId?, unitId? }` places the member in the unit's group with the resident role; first resident = reservation `owner`, later = `participant`; move by sending another unit, `unit: null` to unbind; `404 KS005` for an unknown `addressNo`. A unit is never an identity key. - **Office `employeeId`-only members** — valid without `email`/`phone` (no login). A tenant-mapped `email` domain provisions with the tenant's default role so the Office add-in sign-in lands on the same user. ### Version 1.10.0 (2026-09) - **New: [partner-managed members and credentials](#5-provision-members-and-credentials-partner-managed)** — `PUT/GET/DELETE /partner/members/{externalUserId}` and `PUT /partner/members/{externalUserId}/credentials` for apps that own their users, card numbers and face photos. App-token only, single-project apps, ownership keyed on YOUR user id (same rules as `act_as`). Access groups are fixed at app provisioning (`partnerAccessGroupIds`) — you never pick devices. Card numbers default to Hikvision `decimal-reversed`; faces are base64 JPEG/PNG (10 MB) and **require `faceConsentAt`**; a member with a face and no physical card gets ONE auto-generated card (`generated: true`) to print. Offboarding keeps the link so a re-`PUT` re-activates the same member. ### Version 1.9.0 (2026-08) - **`GET /meetings` now expands recurring series** when `from`/`to` are supplied — see [List Meetings](#list-meetings). One row per occurrence overlapping your window, each carrying that occurrence's real times plus `seriesMasterId` and `occurrenceEventId`. **Breaking for consumers that assume `_id` is unique in a list response** — every occurrence of one series repeats the master `_id`; key on `occurrenceEventId` when present. `_id` remains the id you act on, and `GET /meetings/:id` still returns the master (whose window rolls forward as the series progresses), not an occurrence. Non-recurring bookings are unchanged and carry neither field. ### Version 1.8.0 (2026-07) - **New: [per-event payload reference](#per-event-payload-reference)** — exact `data` fields, types, and sample JSON for every webhook event type (booking + visitor), sourced from the emitting code. Includes the base fields on every event, the `HH:mm` wall-clock convention for booking times, and the `cancelReason` values. ### Version 1.7.1 (2026-07) - **Docs moved under `/docs/`** — canonical URLs are now `/docs/onboarding.md`, `/docs/integration-guide.md`, `/docs/openapi.json` (old root URLs return a permanent redirect, so existing links keep working). `/llms.txt` and `/llms-full.txt` stay at the domain root per the llms.txt convention (also mirrored at `/docs/llms.txt` + `/docs/llms-full.txt`). ### Version 1.7.0 (2026-07) - **New: [`act_as` delegation grant](#4-act-on-behalf-of-a-member-act_as--advanced)** — mint a short-lived, member-scoped token on behalf of one of your own federated members, no per-user password or login. RFC-8693 token-exchange shaped; the delegated token is authorized at `member ∩ app` scope and is not refreshable. Added error codes `KS221` (not authorized to act as subject) and `KS223` (subject not resolved). ### Version 1.6.0 (2026-07) - **New: visitor (VMS) webhook events** — `VMS_INVITE_SENT`, `VMS_VISITOR_CHECKED_IN`, and `VMS_HOST_CANCELLED` now deliver on the existing webhook channel (same signature, retry, and auth policy). `VMS_INVITE_SENT` intentionally carries `qrCardCode`, the visitor's building-access QR, for the partner to relay to the invitee — treat it as a credential. See the [visitor events catalog](#event-catalog). - **Clarified `KSxxx` codes are not 1:1 with HTTP status** — `KS006` spans `409`/`410` and `KS004` spans `500`/`503`; branch on the HTTP status, use the code for telemetry. ### Version 1.5.0 (2026-07) - **`custom-header` now supports multiple headers** — register a list of `{ name, value }` pairs instead of a single header, for partner ingress that requires several (e.g. `X-Api-Key` + `X-Client-Id` + `X-Api-Version`). ### Version 1.4.0 (2026-07) - **New: [Authenticating to your endpoint](#authenticating-to-your-endpoint)** — webhooks can now present a credential your own ingress requires, on top of the always-sent HMAC signature: `signature-only` (default), `bearer` token, `custom-header` (API key), or a short-lived `jwt` (HS256, signed with the shared secret) with a verification snippet. ### Version 1.3.0 (2026-07) - **New: [Receiving Events — Webhooks](#receiving-events--webhooks)** — register a listener server and Keyspace POSTs signed lifecycle events to it: delivery request format, HMAC-SHA256 signature verification (with code sample), retry/auto-disable semantics, and the full event catalog including the new `ROOM_/DESK_NO_SHOW_WARNING` events and `cancelReason` values distinguishing user cancels from no-show and payment-lapse auto-cancels. ### Version 1.2.0 (2026-07) - **Corrected the meeting lifecycle**: a `checked-in` booking **cannot be cancelled** — it ends via check-out (organizer or `pinCode`) or automatically at the booking's end time. Previous doc wrongly listed `checked-in → cancelled`. - **New: [Notifications & Automatic Status Transitions](#notifications--automatic-status-transitions)** — full catalog of the booking scheduler (`pre-start`, `post-start-warn`, no-show auto-cancel at `post-start-expire`, `pre-end`, unpaid payment-lapse auto-cancel), per-asset timing via `config.meetingConfig` (with `-1` disable sentinel), and reconciliation guidance for statuses that change without an API call. - Documented `config.meetingConfig` in the asset config table; clarified cancel authorization (organizer, `pinCode`, or project admin); lifecycle rewritten as an explicit transition list. ### Version 1.1.0 (2026-07) - **Corrected the response envelope**: success is `{code: "KS000", success: true, message, data, meta?}`; errors are `{code, message, success: false}` (previous doc showed a `status: "success"/"error"` field that does not exist). - **Corrected HTTP status codes**: create returns `201`; cancel/check-in/check-out return `202`. - **Corrected error codes**: time-slot conflict is `409 KS012` (not `TIME_SLOT_CONFLICT`); added `KS001`, `KS006`, `KS036`; documented the 400 validation shape (`message` as string array). - **Corrected `paymentChannel` values**: `prompt-pay`, `credit-card`, `alipay`, `true-money`, `we-chat` (hyphenated). - **Corrected `GET /meeting-rooms`**: `projectId` is required; removed non-existent `brandId`/`limit`/`offset` parameters (endpoint is not paginated); added `ids`, `name`, `locationId`, `minCapacity` filters. - **New: availability search** on `GET /meeting-rooms` via `startDateTime`/`endDateTime` + `unavailableReason`. - **New endpoints documented**: `GET /meetings` (list), `GET /meetings/:id` (detail), `GET /desks`, `GET /desks/:id`. - **New: desk bookings** via `POST /meetings` with `deskId` + either an explicit `endDateTime` or a preset `timePeriod`. - **New: explicit-window desk availability search** — `GET /desks?startDateTime=…&endDateTime=…` as an alternative to the preset `timePeriod` filter. - **Doc: desk availability search made explicit** — the Desks section now documents availability search on par with meeting rooms, including that busy desks are excluded from the result (no `unavailableReason` flag) and that `timePeriod` presets are desk-only. - **New fields documented**: `frontendRedirectUrl`, `draftEndDateTimes`, `config.dayConfig` (per-day operating hours + break times — source of truth), `maxDurationMinutes`, `bookMultipleDayRoom`, `chargeRate`. - **New status documented**: `rejected`. - Token lifetimes are environment configuration — always derive expiry from `expires_in` in the response. - Removed the legacy example code that referenced non-existent `/auth/token` and `/auth/refresh` endpoints; fixed malformed JSON in cURL examples. ### Version 1.0.0 - Initial API release: OAuth 2.0 client-credentials auth, meeting-room listing, meeting lifecycle operations.