Skip to content

Keyspace Third-Party Application API Documentation

Version 1.11.1

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:

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

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

json
{
  "firstName": "Somchai",
  "lastName": "Jaidee",
  "profileName": "Chai",
  "email": "[email protected]",
  "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 anotherexternalUserId (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 <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:

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

http
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. 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": "[email protected]",
      "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:

FieldDescription
dayConfigSource of truth for operating hours. Per-weekday (MonSun) 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).

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

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": "[email protected]",
      "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": "[email protected]", "type": "required" },
    { "email": "[email protected]", "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:

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

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": "[email protected]",
      "name": "John Organizer",
      "displayName": "John Organizer"
    },
    "attendees": [
      {
        "userId": "60d5ec49f1b2c72b8c8e4a2b",
        "email": "[email protected]",
        "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": "[email protected]",
      "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). 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 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:

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

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

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 → donecancel 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-indone); 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. 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:

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": "[email protected]"
  }
}
  • 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:

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:

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

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

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, 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, 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 eventdata 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).
  • FormatsbookingDate / 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 deskROOM_* 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
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": "[email protected]"
}

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

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

VMS_INVITE_SENT — visitor invite issued

FieldTypeNotes
visitorName, visitorEmailstringthe invitee
hostName, hostEmailstringthe host
visitDateISO 8601
startDateTime, endDateTimestringpreformatted display strings
qrCardCodestringthe 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": "[email protected]",
  "hostName": "Anan P.",
  "hostEmail": "[email protected]",
  "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)

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
startDateTime, endDateTimestringpreformatted 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

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 — 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

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: "[email protected]", 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) — 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 → emailphone (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 bindingunit: { 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 credentialsPUT/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 eventsVMS_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 statusKS006 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 searchGET /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.