# Keyspace Third-Party Application API Documentation **Version 1.11.1** ## 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. - 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. 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, "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`). **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. > > 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 | | `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. - 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. - 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`). --- ### 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"`) | | `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` | 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`, `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`, `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"` | | `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 | | #### `VMS_INVITE_SENT` — visitor invite issued | Field | Type | Notes | | --- | --- | --- | | `visitorName`, `visitorEmail` | string | the invitee | | `hostName`, `hostEmail` | string | the host | | `visitDate` | ISO 8601 | | | `startDateTime`, `endDateTime` | string | preformatted display strings | | `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" } ``` #### `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`, `endDateTime` | string | preformatted display strings | | `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 — 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 | > **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.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.