Keyspace Third-Party Application API Documentation

Version 1.15.0

Table of Contents

  1. Introduction
  2. API Conventions
  3. Authentication
  4. Meeting Rooms
  5. Desks
  6. Meeting Operations
  7. Meeting Lifecycle
  8. Notifications & Automatic Status Transitions
  9. Receiving Events — Webhooks
  10. Error Handling
  11. Best Practices
  12. Example Integration Workflow
  13. 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.

EnvironmentBase URL
Developmenthttps://api.keyspace-dev.com
UAThttps://api.keyspace-qat.com
Productionhttps://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:

{
  "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).
  • data — the payload (object or array).
  • meta — present only on paginated list endpoints: { limit, offset, total }.

Error:

{
  "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:

Content-Type: application/json

Request Body:

{
  "app_id": "your_app_id_here",
  "app_secret": "your_app_secret_here",
  "grant_type": "app_credentials"
}

Response (Success — 201 Created):

{
  "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 <access_token>.

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:

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:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

3. Refresh Access Token

Endpoint: POST /auth/accessToken (same endpoint, different grant)

Request Body:

{
  "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:

{
  "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 <token>).

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

{
  "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:

{
  "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.

{
  "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 <UID>).
  • 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:

{
  "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

Endpoint: GET /meeting-rooms

Request Headers:

Authorization: Bearer {access_token}

Query Parameters:

ParameterTypeRequiredDescription
projectIdstringYesProject (property) to list rooms for
zoneIdstringNoFilter by zone
floorIdstringNoFilter by floor
locationIdstringNoFilter by location
idsstring[]NoRestrict to specific room IDs
namestringNoFilter by room name
statusstring[]Noactive, inactive (repeatable)
typestringNonormal, bedroom
minCapacitynumberNoOnly rooms with at least this capacity
startDateTimestringNoAvailability window start (ISO 8601 with offset) — see below
endDateTimestringNoAvailability 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

{
  "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:

FieldDescription
dayConfigSource 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 / endLegacy overall operating hours. Kept for backward compatibility — prefer dayConfig when present.
minimumLeadTimeMinimum advance notice for a booking: {minutes, hours, days} before start time
maxAdvanceBookingDaysHow far in the future a booking may start
maxDurationMinutesMaximum booking length, when configured
bookMultipleDayRoomWhether a single booking may span multiple days
meetingConfigNotification/auto-cancel timings for this asset: preStartMinutes, postStartWarnMinutes, postStartExpireMinutes (no-show auto-cancel; -1 disables), preEndMinutes — see 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).

cURL Example — rooms free tomorrow 14:00–15:00:

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). 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.

Endpoint: GET /desks

Query Parameters:

ParameterTypeRequiredDescription
projectIdstringNoFilter by project
brandIdstringNoFilter by brand
zoneIdstringNoFilter by zone
floorIdstringNoFilter by floor
locationIdstringNoFilter by location
idsstring[]NoRestrict to specific desk IDs
namestringNoFilter by desk name
emailstringNoFilter by desk resource email
statusstringNoOne of active, inactive, in-used, reserved, suspended
statusesstring[]NoMultiple statuses
startDateTimestringNoAvailability window start (ISO 8601 with offset)
endDateTimestringNo*Availability window end — explicit window form
timePeriodstringNo*morning, afternoon, allDay — preset window form. *With startDateTime, provide exactly one of endDateTime / timePeriod
limitnumberNoPage size (max 3000)
offsetnumberNoItems 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).

Omit all three time params for a plain, unfiltered desk listing.

cURL Example — desks free tomorrow morning:

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.

{
  "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:

Authorization: Bearer {access_token}
Content-Type: application/json

Request Body (room booking):

{
  "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:

{
  "deskId": "60d5ec49f1b2c72b8c8e4b10",
  "title": "Work from HQ",
  "startDateTime": "2026-07-15T09:00:00+07:00",
  "endDateTime": "2026-07-15T12:30:00+07:00"
}
{
  "deskId": "60d5ec49f1b2c72b8c8e4b10",
  "title": "Work from HQ",
  "startDateTime": "2026-07-15T00:00:00+07:00",
  "timePeriod": "morning"
}

Request Body Fields:

FieldTypeRequiredDescription
meetingRoomIdstringYes*Meeting room to book (*exactly one of meetingRoomId / deskId)
deskIdstringYes*Desk to book
titlestringYesBooking title
bodystringNoDescription / agenda
startDateTimestringYesStart (ISO 8601 with timezone offset)
endDateTimestringYes**End (**desk bookings: provide exactly one of endDateTime / timePeriod)
timePeriodstringNoDesk bookings only: morning, afternoon, allDay — mutually exclusive with endDateTime
attendeesarrayNoAttendee list
attendees[].emailstringYesAttendee email
attendees[].idstringNoKeyspace user ID, if known
attendees[].typestringNorequired or optional (default required)
estimatedAttendancenumberNoExpected participant count
paymentChannelstringNoprompt-pay (default), credit-card, alipay, true-money, we-chat
discountCodestringNoDiscount / promo code
organizerIdstringNoBook 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
paymentDueMinutesnumberNoBack-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
frontendRedirectUrlstringNoURL the payer is redirected to after completing web payment (use your app's return/deep-link URL)
draftbooleanNotrue = price preview only, nothing is created (default false)
draftEndDateTimesarrayNoWith draft: true: preview prices for several candidate end times in one call

Response (Success — 201 Created):

Free booking (no charge):

{
  "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):

{
  "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). 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 rather than steering the user to a different space. Because there is no idempotency key, reconcile with GET /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 to reconcile instead.

List Meetings

Query bookings — power "my bookings" screens, day timelines, and reconciliation.

Endpoint: GET /meetings

Query Parameters (most used):

ParameterTypeRequiredDescription
fromstringYesWindow start (ISO 8601 with offset)
tostringYesWindow end
projectIdstringNoFilter by project
meetingRoomIdstringNoFilter by room (also meetingRoomIds for multiple)
deskIdstringNoFilter by desk (also deskIds)
organizerIdstringNoFilter by organizer user ID
organizerEmailsstring[]NoFilter by organizer email(s)
attendeeEmailsstring[]NoFilter by attendee email(s)
statusesstring[]Noactive, unpaid, checked-in, done, cancelled, rejected
assetTypestringNoRestrict to room or desk bookings
limit / offsetnumberNoPagination (max 3000 / default 0)

Response (Success — 200 OK): paginated list of meeting objects (same shape as 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:

FieldMeaning
seriesMasterIdPresent ONLY on an expanded occurrence. Equals the series master _id
occurrenceEventIdPresent 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:

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):

FieldTypeDescription
titlestringUpdated title
bodystringUpdated description
startDateTimestringNew start (cannot change after check-in)
endDateTimestringNew end — extend or shorten
timePeriodstringDesk bookings: morning / afternoon / allDay
attendeesarrayReplacement attendee list
estimatedAttendancenumberUpdated participant count
pinCodestringRequired when the caller is not the organizer
paymentChannelstringPayment channel if the change requires payment (same values as create)
discountCodestringPromo code for the additional charge
frontendRedirectUrlstringPost-payment redirect for the additional charge
draftbooleantrue = preview the price of the change without applying it
draftEndDateTimesarrayWith 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): 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):

FieldTypeDescription
pinCodestringRequired when the caller is not the organizer
paymentIdstringPayment 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 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):

FieldTypeDescription
pinCodestringRequired 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):

FieldTypeDescription
pinCodestringRequired 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.
StatusMeaningLeaves this state via
unpaidCreated, awaiting paymentpayment → active; cancel or ~5-min payment lapse → cancelled
activeConfirmedcheck-in → checked-in; cancel or no-show auto-cancel → cancelled; end time → done
checked-inAttendee(s) presentcheck-out or end time → done — cancel not allowed
doneCompleted (checked out or ended)terminal
cancelledCancelled by user or system (payment lapse / no-show)terminal
rejectedDeclined by the room's calendar provider or an administratorterminal

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

EventWhen (default)Effect
createon booking confirmationnotification only
updateon booking changenotification only
cancelon cancellationnotification only
pre-start15 min before startreminder to participants
post-start-warn10 min after startwarning: check in now or the booking will be auto-cancelled
post-start-expire15 min after startAUTO-CANCEL — an active booking nobody checked in to flips to cancelled
pre-end15 min before endending-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:

"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. 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 types 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:

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
{
  "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:

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:

SchemeHeader Keyspace addsYou provide
signature-only (default)(none — verify the signature)nothing
bearerAuthorization: Bearer <token>a static token
custom-headerone or more headers, each <YourHeaderName>: <value> (e.g. X-Api-Key + X-Client-Id)a list of header name/value pairs
jwtAuthorization: Bearer <JWT>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: <projectId>, jti: <delivery id>, exp. Verify it with HS256 and the same shared secret you use for the signature:

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

typeFiresKey data fields
ROOM_BOOK / DESK_BOOKbooking confirmedroomName/deskName, bookingDate, startTime, endTime, userName, userEmail
ROOM_MODIFY / DESK_MODIFYbooking changedoldStartTime, oldEndTime, newStartTime, newEndTime
ROOM_CANCEL / DESK_CANCELbooking cancelled — user action or system auto-cancelbookingDate, 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 startstartTime, endTime, minutesUntilStart
ROOM_NO_SHOW_WARNING / DESK_NO_SHOW_WARNING~10 min after start, nobody checked instartTime, endTime, minutesUntilAutoCancel
ROOM_ENDING_SOON / DESK_ENDING_SOON~15 min before endendTime, minutesUntilEnd
ROOM_PAYMENT_DUE / DESK_PAYMENT_DUEa back-office booking (bookedVia: "backoffice") is held unpaid — sent at create and once more at ~75 % of the holdbookingDate, 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 types the same way you subscribe to booking events.

typeFiresKey data fields
VMS_INVITE_SENTa visitor invite is issuedvisitorName, 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_INa visitor checks in (card or license plate)visitorName, hostAssetTitle, checkInTime ({ en, th }), vmsEntryType, licensePlateNumber, location?, webViewUrl?
VMS_HOST_CANCELLEDthe host cancels the visitvisitorName, hostName, hostEmail, startDateTime, endDateTime (null = no end time), licensePlate?

Only these three visitor types 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

FieldTypeNotes
roomName | deskNamestringbooked asset
floorNamestringasset's floor
bookingDateISO 8601booking start instant
startTime, endTimeHH:mmproperty-local wall-clock
attendees?string[]attendee display names
userName?, userEmail?stringorganizer
{
  "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

FieldTypeNotes
roomName | deskName, floorNamestring
oldStartTime, oldEndTimeHH:mmwindow before the change
newStartTime, newEndTimeHH:mmwindow after the change
userName?stringwho changed it

ROOM_CANCEL / DESK_CANCEL — booking cancelled

FieldTypeNotes
roomName | deskName, floorNamestring
bookingDateISO 8601
cancelReason?stringabsent = user/admin cancel; "No-show — auto-cancelled by system"; "Payment window lapsed — auto-cancelled by system"; "Unpaid — back-office hold expired"
userName?string
{
  "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

FieldTypeNotes
roomName | deskName, floorNamestring
startTime, endTimeHH:mm
minutesUntilStartnumberper-asset preStartMinutes (default 15)
userName?, userEmail?string

ROOM_NO_SHOW_WARNING / DESK_NO_SHOW_WARNING — no check-in, auto-cancel imminent

FieldTypeNotes
roomName | deskName, floorNamestring
startTime, endTimeHH:mm
minutesUntilAutoCancelnumbertime left to check in before the no-show cancel
userName?string

ROOM_ENDING_SOON / DESK_ENDING_SOON — ~15 min before end

FieldTypeNotes
roomName | deskName, floorNamestring
endTimeHH:mm
minutesUntilEndnumberper-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".

FieldTypeNotes
roomName | deskName, floorNamestring
bookingDateISO 8601
startTime, endTimeHH:mm
amountnumberthe amount still due (the staff quote until the resident mints a payment)
currencystringTHB
paymentDueAtISO 8601the hold deadline — same instant as GET /meetings/{id} paymentDueAt
paymentDueTimestringpaymentDueAt pre-formatted (dd MMM yyyy HH:mm)
minutesUntilDuenumberat send time
userName?, userEmail?stringthe resident

VMS_INVITE_SENT — visitor invite issued

FieldTypeNotes
visitorName, visitorEmailstringthe invitee
hostName, hostEmailstringthe host
visitDateISO 8601
startDateTimestringpreformatted display string (site local time)
endDateTimestring | nullpreformatted 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
qrCardCodestringthe visitor's building-access QR — relay to the invitee; treat as a credential (TLS only, never log)
visitPurpose?string
licensePlate?string
{
  "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)

FieldTypeNotes
visitorNamestring
hostAssetTitlestringthe host's unit/asset
checkInTime{ en, th }localized display strings
vmsEntryType{ entryType, en, th }how they entered (card / plate)
licensePlateNumberstringempty when card entry
location?{ entryType, en, th }entry point, when known
webViewUrl?stringKeyspace visit web view

VMS_HOST_CANCELLED — host cancelled the visit

FieldTypeNotes
visitorName, hostName, hostEmailstring
startDateTimestringpreformatted display string (site local time)
endDateTimestring | nullpreformatted display string; null when the invite had no end time
licensePlate?string

Error Handling

Error envelope

{
  "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:

{
  "code": "KS001",
  "message": [
    "title should not be empty",
    "startDateTime must be a valid ISO 8601 date string"
  ],
  "success": false
}

Error code reference

HTTPCodeMeaningHandling
400KS001Bad request / validation failedFix the request; surface message[] details
401KS002Unauthorized (missing/expired token)Refresh or re-authenticate, retry once
403KS003Forbidden (no access to resource)Check the project/room your credentials are scoped to
404KS005Resource not foundVerify IDs
409 / 410KS006State 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
409KS012Reservation time-slot conflictExpected on create/update races, or when the booking runs into the cleaning time — offer another slot
429KS036Too many requestsBack off and retry with exponential delay
403KS221act-as / partner members: app not authorized for this subjectOnly act as / manage your OWN linked members, within your project scope; external_user_id routing needs a single-project app
401KS223act-as / partner members: subject could not be resolvedAssert a valid subject/subject_type (act-as); on /partner/members/* the member row is missing — contact Keyspace
500 / 503KS004Internal / upstream errorRetry with backoff (max 3); report if persistent. 503 signals a transient upstream dependency — the same retry strategy applies
503KS234Authentication 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

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): 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) — 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) — 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, §Create Meeting, §Update/Extend 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-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) — 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) — 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). 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) — 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) — 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) — 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 — 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. 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 — 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 — 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.
  • 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 — 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 — 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 — 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.

On this page

Table of ContentsIntroductionAPI ConventionsResponse envelopeHTTP status codesDate-times and identifiersPaginationAuthenticationOverview1. Generate Access Token2. Using the Access Token3. Refresh Access Token4. Act on behalf of a member (act_as) — advanced5. Provision members and credentials (partner-managed)Meeting RoomsList Meeting Rooms (with availability search)Get Single Meeting RoomDesksList Desks (with availability search)Get Single DeskMeeting OperationsCreate MeetingList MeetingsGet Meeting DetailUpdate/Extend MeetingCancel MeetingCheck-In to MeetingCheck-Out from MeetingMeeting LifecycleNotifications & Automatic Status TransitionsThe scheduler ticksPer-asset timing configurationWhat this means for your integrationReceiving Events — WebhooksRegistrationThe delivery requestVerifying the signature (required)Authenticating to your endpointResponding, retries, and the failure policyEvent catalogPer-event payload referenceError HandlingError envelopeValidation errors (400)Error code referenceBest Practices1. Token management2. Retries and idempotency3. Booking flow4. Payments5. Data hygieneExample Integration WorkflowChangelogVersion 1.15.0 (2026-10)Version 1.14.0 (2026-10)Version 1.13.1 (2026-10)Version 1.13.0 (2026-10)Version 1.12.2 (2026-09)Version 1.12.1 (2026-09)Version 1.12.0 (2026-09)Version 1.11.3 (2026-09)Version 1.11.2 (2026-09)Version 1.11.1 (2026-09)Version 1.11.0 (2026-09)Version 1.10.0 (2026-09)Version 1.9.0 (2026-08)Version 1.8.0 (2026-07)Version 1.7.1 (2026-07)Version 1.7.0 (2026-07)Version 1.6.0 (2026-07)Version 1.5.0 (2026-07)Version 1.4.0 (2026-07)Version 1.3.0 (2026-07)Version 1.2.0 (2026-07)Version 1.1.0 (2026-07)Version 1.0.0