Appearance
Keyspace Third-Party Application API Documentation
Version 1.11.1
Table of Contents
- Introduction
- API Conventions
- Authentication
- Meeting Rooms
- Desks
- Meeting Operations
- Meeting Lifecycle
- Notifications & Automatic Status Transitions
- Receiving Events — Webhooks
- Error Handling
- Best Practices
- Example Integration Workflow
- Changelog
Introduction
This documentation is for third-party developers integrating room and desk booking with the Keyspace Smart Building System. The API is RESTful and uses OAuth 2.0 client-credentials authentication.
| Environment | Base URL |
|---|---|
| Development | https://api.keyspace-dev.com |
| UAT | https://api.keyspace-qat.com |
| Production | https://api.keyspace.tech |
Use the environment your credentials were issued for. Credentials are not shared across environments.
API Conventions
Response envelope
Every JSON response (except the token endpoint) is wrapped in a common envelope:
Success:
json
{
"code": "KS000",
"success": true,
"message": "Success",
"data": { "...": "..." },
"meta": { "limit": 20, "offset": 0, "total": 42 }
}code—"KS000"on success; aKSxxxerror 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
GETendpoints return200 OK.POST /auth/accessTokenandPOST /meetingsreturn201 Created.PATCH /meetings/:idreturns200 OK.POST /meetings/:id/cancel,/check-in, and/check-outreturn202 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, formattedHH: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 identifierapp_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/jsonRequest 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_typefield. Always send the token asAuthorization: Bearer <access_token>.
Response Fields:
access_token— bearer token for API requestsexpires_in— access-token lifetime in secondsrefresh_token— token used to obtain a new access tokenrefresh_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_invalues 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 withapp_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"
}subjectis 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 samesubjectalways maps to the same member. For a member you've already linked you may instead pass its Keyspace member id withsubject_type: "keyspace_member_id".external_user_idact-as requires a single-project app. A multi-project app must usekeyspace_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…" }
}firstNameandlastNameare required (an emptylastNameis accepted). Everything else is optional; omitted = untouched.email/phonemust be unique across Keyspace users — a clash is409.employeeId(1–64 chars, your HR / badge id) must be unique within the project — a clash is409. 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 — samememberId, 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:
- the member already linked to this
externalUserId(your previous PUT); email→ an existing Keyspace user (case-insensitive);phone→ an existing user, after normalization (spaces/dashes stripped, Thai numbers default to+66);employeeId→ a member of the project carrying that employee id;- 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.linkedExternalUserIdtells you which) — e.g. two of your users sharing oneemployeeId; employeeId/email/phonealready taken by someone else;unit.addressNoexists in several zones (towers) and you sent nounit.zoneId.
By vertical.
- Office / workplace apps — send
employeeId;emailtoo 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).employeeIdalone 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 withunit:{ addressNo, zoneId?, unitId? }.addressNois the house number the building admin entered on the room (404if the project has no such room — partners never create rooms);unitIdis 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: nullunbinds.
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.cardNois read incardCodeFormat, which defaults todecimal-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 is400.labelis optional (defaults toCard <UID>).
face— a base64 JPEG or PNG (bare ordata:image/…;base64,), max 10 MB decoded; the type is verified by magic bytes, not by your prefix.nullerases the stored face (PDPA right to erasure). A string sets/replaces it and requiresfaceConsentAt— the ISO-8601 instant the person consented to biometric processing; the request is400without 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 itscardNoand hand it to the member — it opens the same doors as an enrolled card. It is never generated twice, and physical cards you supply later do not retire it. (Offboarding revokes it; a re-onboarded member gets a fresh number — the old card stays dead.)
Response 200:
json
{
"code": "KS000",
"success": true,
"data": {
"memberId": "66b1f0a2c9e77a0012345678",
"cards": [
{ "cardId": "66c3…", "cardNo": "2140450341", "label": "Resident card", "type": "real", "generated": false },
{ "cardId": "66c4…", "cardNo": "0982716354", "label": "auto-generated (face) 1B2C3D4E", "type": "virtual", "generated": true }
],
"face": { "present": true }
}
}cardNo in every response is always the Hikvision decimal-reversed number, whatever format you submitted. Requires the member to be active — 409 when offboarded (re-PUT the member first).
GET /partner/members/{externalUserId} — read
Returns memberId, externalUserId, projectId, status (active | deactivated), source (how the membership was created — partner for members this surface made, federated for act_as-only subjects, otherwise the admin/SSO door it came through), userType (member | visitor), accessGroupIds, employeeId, unit (as in the PUT response, or null), profile (firstName, lastName, profileName, email, phone) and credentials (cards as above, face: { present }). Never creates a member.
DELETE /partner/members/{externalUserId} — offboard
204. Deactivates the member in the project: every card (physical and the auto-generated one) is revoked, device access is withdrawn, and the member is removed from your access groups. The link to your externalUserId is kept, so a later PUT re-activates the same member instead of creating a duplicate. Idempotent.
Errors on this surface: 403 KS003 — not an app token · 403 KS221 — not your member / not a single-project app · 401 KS223 — the member could not be resolved · 400 KS001 — validation (missing consent, bad card number, bad image) · 404 KS005 — unit names a room the project does not have · 409 KS006 — member offboarded (credentials), duplicate email/phone/employeeId, or an identity-key conflict (data.conflictKey, see the PUT) · 429 KS036 — rate limited.
Meeting Rooms
List Meeting Rooms (with availability search)
Endpoint: GET /meeting-rooms
Request Headers:
http
Authorization: Bearer {access_token}Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
projectId | string | Yes | Project (property) to list rooms for |
zoneId | string | No | Filter by zone |
floorId | string | No | Filter by floor |
locationId | string | No | Filter by location |
ids | string[] | No | Restrict to specific room IDs |
name | string | No | Filter by room name |
status | string[] | No | active, inactive (repeatable) |
type | string | No | normal, bedroom |
minCapacity | number | No | Only rooms with at least this capacity |
startDateTime | string | No | Availability window start (ISO 8601 with offset) — see below |
endDateTime | string | No | Availability window end — required together with startDateTime |
Availability search: pass
startDateTime+endDateTimeto 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 anunavailableReasonfield (e.g."OPERATING_HOURS"— outside the room's operating hours, or"BREAK_TIME"— the window overlaps a configured break). Absence ofunavailableReasonmeans 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 KS012on 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:
| Field | Description |
|---|---|
dayConfig | Source of truth for operating hours. Per-weekday (Mon…Sun) availableTime {start, end} plus optional breakTimes[] during which booking is blocked. A missing day means the room is not bookable that day. |
start / end | Legacy overall operating hours. Kept for backward compatibility — prefer dayConfig when present. |
minimumLeadTime | Minimum advance notice for a booking: {minutes, hours, days} before start time |
maxAdvanceBookingDays | How far in the future a booking may start |
maxDurationMinutes | Maximum booking length, when configured |
bookMultipleDayRoom | Whether a single booking may span multiple days |
meetingConfig | Notification/auto-cancel timings for this asset: preStartMinutes, postStartWarnMinutes, postStartExpireMinutes (no-show auto-cancel; -1 disables), preEndMinutes — see Notifications & Automatic Status Transitions |
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.
List Desks (with availability search)
Endpoint: GET /desks
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
projectId | string | No | Filter by project |
brandId | string | No | Filter by brand |
zoneId | string | No | Filter by zone |
floorId | string | No | Filter by floor |
locationId | string | No | Filter by location |
ids | string[] | No | Restrict to specific desk IDs |
name | string | No | Filter by desk name |
email | string | No | Filter by desk resource email |
status | string | No | One of active, inactive, in-used, reserved, suspended |
statuses | string[] | No | Multiple statuses |
startDateTime | string | No | Availability window start (ISO 8601 with offset) |
endDateTime | string | No* | Availability window end — explicit window form |
timePeriod | string | No* | morning, afternoon, allDay — preset window form. *With startDateTime, provide exactly one of endDateTime / timePeriod |
limit | number | No | Page size (max 3000) |
offset | number | No | Items to skip (default 0) |
Availability search: pass
startDateTimeplus exactly one ofendDateTime(explicit window) ortimePeriod(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
timePeriodpreset is accepted (rooms take an explicit window only).- Desks with a conflicting booking are removed from the result entirely (
meta.totalreflects the free count), rather than returned with anunavailableReasonflag 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/jsonRequest 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:
| Field | Type | Required | Description |
|---|---|---|---|
meetingRoomId | string | Yes* | Meeting room to book (*exactly one of meetingRoomId / deskId) |
deskId | string | Yes* | Desk to book |
title | string | Yes | Booking title |
body | string | No | Description / agenda |
startDateTime | string | Yes | Start (ISO 8601 with timezone offset) |
endDateTime | string | Yes** | End (**desk bookings: provide exactly one of endDateTime / timePeriod) |
timePeriod | string | No | Desk bookings only: morning, afternoon, allDay — mutually exclusive with endDateTime |
attendees | array | No | Attendee list |
attendees[].email | string | Yes | Attendee email |
attendees[].id | string | No | Keyspace user ID, if known |
attendees[].type | string | No | required or optional (default required) |
estimatedAttendance | number | No | Expected participant count |
paymentChannel | string | No | prompt-pay (default), credit-card, alipay, true-money, we-chat |
discountCode | string | No | Discount / promo code |
frontendRedirectUrl | string | No | URL the payer is redirected to after completing web payment (use your app's return/deep-link URL) |
draft | boolean | No | true = price preview only, nothing is created (default false) |
draftEndDateTimes | array | No | With draft: true: preview prices for several candidate end times in one call |
Response (Success — 201 Created):
Free booking (no charge):
json
{
"code": "KS000",
"success": true,
"message": "Success",
"data": {
"_id": "60d5ec49f1b2c72b8c8e4a50",
"meetingRoomId": "60d5ec49f1b2c72b8c8e4a1b",
"meetingRoomName": "Conference Room A",
"title": "Product Strategy Meeting",
"body": "Discussing Q3 product roadmap",
"status": "active",
"startDateTime": "2026-07-15T14:00:00+07:00",
"endDateTime": "2026-07-15T15:30:00+07:00",
"pinCode": "123456",
"organizer": {
"userId": "60d5ec49f1b2c72b8c8e4a2a",
"email": "[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), meetminimumLeadTime, start withinmaxAdvanceBookingDays, not exceedmaxDurationMinutes, and avoidbreakTimes. - A
status: "unpaid"response means payment is required. Presentpayment.webPaymentUrl(or thewebPaymentQrimage) 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
frontendRedirectUrlso the payer lands back in your application after paying. - Capture and store
pinCodefrom 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
activebooking 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
409with codeKS012— 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 /meetingsto reconcile instead.
List Meetings
Query bookings — power "my bookings" screens, day timelines, and reconciliation.
Endpoint: GET /meetings
Query Parameters (most used):
| Parameter | Type | Required | Description |
|---|---|---|---|
from | string | Yes | Window start (ISO 8601 with offset) |
to | string | Yes | Window end |
projectId | string | No | Filter by project |
meetingRoomId | string | No | Filter by room (also meetingRoomIds for multiple) |
deskId | string | No | Filter by desk (also deskIds) |
organizerId | string | No | Filter by organizer user ID |
organizerEmails | string[] | No | Filter by organizer email(s) |
attendeeEmails | string[] | No | Filter by attendee email(s) |
statuses | string[] | No | active, unpaid, checked-in, done, cancelled, rejected |
assetType | string | No | Restrict to room or desk bookings |
limit / offset | number | No | Pagination (max 3000 / default 0) |
Response (Success — 200 OK): paginated list of meeting objects (same shape as Get Meeting Detail), with meta.
Recurring series — read this if you consume from/to. A recurring booking is stored as ONE record (the series master) that carries its occurrences internally. When you pass from/to, this endpoint expands the series for you: you get one row per occurrence that overlaps your window, each with that occurrence's real startDateTime/endDateTime (never truncated to your window), plus two extra fields:
| Field | Meaning |
|---|---|
seriesMasterId | Present ONLY on an expanded occurrence. Equals the series master _id |
occurrenceEventId | Present ONLY on an expanded occurrence. Stable + unique per occurrence |
Three consequences worth designing around:
_idis NOT unique in the response. Every occurrence of one series repeats the master's_id. De-duplicate and key your lists onoccurrenceEventIdwhen it is present, falling back to_id._idis still the right id to ACT on. Check-in, cancel andGET /meetings/:idall take the master_id— the expansion is a read-side convenience, not a separate resource.GET /meetings/:idreturns the MASTER, not an occurrence. ItsstartDateTime/endDateTimedescribe the current occurrence and roll forward as the series progresses, so a detail fetch will not match the occurrence row you clicked. If you need a specific occurrence's window, keep it from the list response.
Ordinary (non-recurring) bookings are unchanged and carry neither field.
cURL Example — today's bookings for one room:
bash
curl -G "https://api.keyspace-qat.com/meetings" \
-H "Authorization: Bearer {access_token}" \
--data-urlencode "from=2026-07-15T00:00:00+07:00" \
--data-urlencode "to=2026-07-16T00:00:00+07:00" \
--data-urlencode "meetingRoomId=60d5ec49f1b2c72b8c8e4a1b"Get Meeting Detail
Endpoint: GET /meetings/:id
Returns one booking: status, times, room/desk, organizer, attendees, pinCode, payment/billing summaries (for paid bookings), and histories (create/update/cancel/check-in/check-out/payment events).
Use this to confirm payment completion after directing a user to webPaymentUrl (poll until status leaves unpaid), and to re-sync state after errors or timeouts.
Update/Extend Meeting
Endpoint: PATCH /meetings/:id
Request Body Fields (all optional unless noted):
| Field | Type | Description |
|---|---|---|
title | string | Updated title |
body | string | Updated description |
startDateTime | string | New start (cannot change after check-in) |
endDateTime | string | New end — extend or shorten |
timePeriod | string | Desk bookings: morning / afternoon / allDay |
attendees | array | Replacement attendee list |
estimatedAttendance | number | Updated participant count |
pinCode | string | Required when the caller is not the organizer |
paymentChannel | string | Payment channel if the change requires payment (same values as create) |
discountCode | string | Promo code for the additional charge |
frontendRedirectUrl | string | Post-payment redirect for the additional charge |
draft | boolean | true = preview the price of the change without applying it |
draftEndDateTimes | array | With draft: preview several candidate end times |
Response (Success — 200 OK): the updated meeting. If the change requires additional payment (e.g. extending a paid booking), the response carries status: "unpaid" plus a payment object — same handling as a paid create.
Important Notes:
- Only
activeorchecked-inbookings can be updated. - Start time cannot change once checked in.
- The change must not conflict with other bookings (
409 KS012).
Cancel Meeting
Endpoint: POST /meetings/:id/cancel
Request Body Fields (optional):
| Field | Type | Description |
|---|---|---|
pinCode | string | Required when the caller is not the organizer |
paymentId | string | Payment to void when cancelling an unpaid booking |
Response (Success — 202 Accepted): the meeting with status: "cancelled" and an appended histories entry.
Important Notes:
- Only
activeandunpaidbookings can be cancelled. - A
checked-inbooking cannot be cancelled — end it with check-out instead (organizer orpinCode). - Bookings that already ended (
done) cannot be cancelled. - Cancellation is authorized for the organizer, a caller presenting the booking's
pinCode, or a project admin. - Pending payments are voided automatically.
- Cancellation is irreversible.
Check-In to Meeting
Endpoint: POST /meetings/:id/check-in
Request Body Fields (optional):
| Field | Type | Description |
|---|---|---|
pinCode | string | Required when the caller is not the organizer |
Response (Success — 202 Accepted): the meeting with status: "checked-in" and per-person checkInDateTime.
Important Notes:
- Check-in opens 15 minutes before start by default (configurable per room).
- Only
activebookings can check in — notunpaid,cancelled,rejected, ordone. Paid bookings must complete payment first. - Multiple attendees can check in independently.
Check-Out from Meeting
Endpoint: POST /meetings/:id/check-out
Request Body Fields (optional):
| Field | Type | Description |
|---|---|---|
pinCode | string | Required when the caller is not the organizer |
Response (Success — 202 Accepted): the meeting with status: "done" and checkOutDateTime set.
Important Notes:
- Only
checked-inbookings can check out. - Check-out is the only way to end a
checked-inbooking — cancellation is no longer possible after check-in. - Check-out ends the booking and releases the room. It cannot be undone.
Meeting Lifecycle
create (free room) ─────────────────────────────────────────► active
create (paid room) ──► unpaid ── payment completed ──────────► active
unpaid ── cancel, or payment window lapses (~5 min) ─────► cancelled
active ── cancel (organizer / pinCode / admin) ──────────► cancelled
active ── no-show auto-cancel (~15 min after start) ─────► cancelled
active ── check-in (opens ~15 min before start) ─────────► checked-in
checked-in ── check-out (organizer / pinCode) ───────────────► done
active / checked-in ── end of booking (automatic) ────────────► done
A checked-in booking can NOT be cancelled — it ends only via check-out
or automatically at the booking's end time.
rejected — the booking was declined by the room's calendar system or an
administrator; terminal, like cancelled.| Status | Meaning | Leaves this state via |
|---|---|---|
unpaid | Created, awaiting payment | payment → active; cancel or ~5-min payment lapse → cancelled |
active | Confirmed | check-in → checked-in; cancel or no-show auto-cancel → cancelled; end time → done |
checked-in | Attendee(s) present | check-out or end time → done — cancel not allowed |
done | Completed (checked out or ended) | terminal |
cancelled | Cancelled by user or system (payment lapse / no-show) | terminal |
rejected | Declined by the room's calendar provider or an administrator | terminal |
Notifications & Automatic Status Transitions
Keyspace runs a notification scheduler for every booking. Some ticks only notify the participants (through the Keyspace apps — push, LINE, email); two of them change the booking's status automatically. Your integration must expect these transitions, because they happen without any API call from you.
The scheduler ticks
| Event | When (default) | Effect |
|---|---|---|
create | on booking confirmation | notification only |
update | on booking change | notification only |
cancel | on cancellation | notification only |
pre-start | 15 min before start | reminder to participants |
post-start-warn | 10 min after start | warning: check in now or the booking will be auto-cancelled |
post-start-expire | 15 min after start | AUTO-CANCEL — an active booking nobody checked in to flips to cancelled |
pre-end | 15 min before end | ending-soon reminder (extend via PATCH /meetings/:id if needed) |
| (unpaid timeout) | ~5 min after create (paid) | AUTO-CANCEL — an unpaid booking whose payment window lapsed flips to cancelled |
At the booking's end time the meeting is closed automatically (active/checked-in → done); no notification is sent for that.
Per-asset timing configuration
The four timed ticks are configurable per room/desk and exposed to you in the asset's config.meetingConfig:
json
"config": {
"meetingConfig": {
"preStartMinutes": 15,
"postStartWarnMinutes": 10,
"postStartExpireMinutes": 15,
"preEndMinutes": 15
}
}- A value of
-1disables that tick.postStartExpireMinutes: -1disables the no-show auto-cancel entirely for that asset. - When
meetingConfigis absent, the defaults above apply. - Read these from
GET /meeting-rooms/GET /desksif 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) ordone(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: -1on 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 /meetingsremains 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…e2a1json
{
"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.versionis the envelope version (currently1); breaking envelope changes bump it.datais a change signal, not the full record — for booking events fetch authoritative state withGET /meetings/:idwhen you need it. Tolerate unknown fields:datamay gain fields without a version bump. It never contains a meetingpinCode, access tokens, or payment URLs. Exception:VMS_INVITE_SENTdeliberately carriesqrCardCode, 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 partnerGETendpoint; they are terminal signals.
Verifying the signature (required)
The signature is sha256=HEX( HMAC-SHA256( secret, timestamp + "." + rawBody ) ) over the exact raw request bytes — verify before trusting the payload, and reject stale timestamps to block replays:
javascript
const crypto = require("node:crypto");
function verifyKeyspaceWebhook(headers, rawBody, secret) {
const timestamp = headers["x-keyspace-timestamp"];
const received = headers["x-keyspace-signature"] ?? "";
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() - Number(timestamp)) < 5 * 60 * 1000;
return (
fresh &&
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
);
}Compute the HMAC over the raw body bytes (before any JSON parsing) — a re-serialized body will not match.
Authenticating to your endpoint
Every delivery is always HMAC-signed (previous section) — that proves it came from Keyspace. Separately, if your endpoint sits behind its own auth, tell your Keyspace contact which scheme to use and Keyspace will add the matching header on top of the signature:
| Scheme | Header Keyspace adds | You provide |
|---|---|---|
signature-only (default) | (none — verify the signature) | nothing |
bearer | Authorization: Bearer <token> | a static token |
custom-header | one or more headers, each <YourHeaderName>: <value> (e.g. X-Api-Key + X-Client-Id) | a list of header name/value pairs |
jwt | Authorization: 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
type | Fires | Key data fields |
|---|---|---|
ROOM_BOOK / DESK_BOOK | booking confirmed | roomName/deskName, bookingDate, startTime, endTime, userName, userEmail |
ROOM_MODIFY / DESK_MODIFY | booking changed | oldStartTime, oldEndTime, newStartTime, newEndTime |
ROOM_CANCEL / DESK_CANCEL | booking cancelled — user action or system auto-cancel | bookingDate, cancelReason (absent = user/admin cancel; "No-show — auto-cancelled by system"; "Payment window lapsed — auto-cancelled by system") |
ROOM_REMINDER / DESK_REMINDER | ~15 min before start | startTime, endTime, minutesUntilStart |
ROOM_NO_SHOW_WARNING / DESK_NO_SHOW_WARNING | ~10 min after start, nobody checked in | startTime, endTime, minutesUntilAutoCancel |
ROOM_ENDING_SOON / DESK_ENDING_SOON | ~15 min before end | endTime, minutesUntilEnd |
Timings follow the asset's config.meetingConfig (see the previous section) — the ~values above are the defaults.
Visitor events (VMS)
Delivered on the same channel for projects that use the Visitor Management System. Subscribe to these types the same way you subscribe to booking events.
type | Fires | Key data fields |
|---|---|---|
VMS_INVITE_SENT | a visitor invite is issued | visitorName, visitorEmail, hostName, hostEmail, visitDate, startDateTime, endDateTime, qrCardCode (access QR — relay to the visitor, treat as a credential), licensePlate?, visitPurpose? |
VMS_VISITOR_CHECKED_IN | a visitor checks in (card or license plate) | visitorName, hostAssetTitle, checkInTime ({ en, th }), vmsEntryType, licensePlateNumber, location?, webViewUrl? |
VMS_HOST_CANCELLED | the host cancels the visit | visitorName, hostName, hostEmail, startDateTime, endDateTime, licensePlate? |
Only these three visitor 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 —
dataalways carriesprojectId(string, hex id — mirrors the envelope),userId(string, hex id — the acting Keyspace user: booking organizer / VMS transactor), andtimestamp(ISO 8601 — mirrors the envelope'soccurredAt). - Formats —
bookingDate/visitDateare ISO 8601 date-times.startTime/endTime/oldStartTime/newStartTime(etc.) on booking events are property-local wall-clockHH:mmstrings. VMSstartDateTime/endDateTimeare preformatted display strings. - Optionality — fields marked
?may be absent. Tolerate absent AND unknown fields;datamay gain fields without a version bump. - Room vs desk —
ROOM_*andDESK_*payloads are identical except the asset field:roomNameon rooms,deskNameon desks. Documented once below withroomName|deskName.
ROOM_BOOK / DESK_BOOK — booking confirmed
| Field | Type | Notes |
|---|---|---|
roomName | deskName | string | booked asset |
floorName | string | asset's floor |
bookingDate | ISO 8601 | booking start instant |
startTime, endTime | HH:mm | property-local wall-clock |
attendees? | string[] | attendee display names |
userName?, userEmail? | string | organizer |
json
{
"projectId": "68faf513142742089f0fb40f",
"userId": "66691e95a1b2c3d4e5f60719",
"timestamp": "2026-07-15T06:55:03.412Z",
"roomName": "Boardroom 7F",
"floorName": "7F",
"bookingDate": "2026-07-15T07:00:00.000Z",
"startTime": "14:00",
"endTime": "15:00",
"attendees": ["Anan P.", "Beam K."],
"userName": "Anan P.",
"userEmail": "[email protected]"
}ROOM_MODIFY / DESK_MODIFY — booking changed
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
oldStartTime, oldEndTime | HH:mm | window before the change |
newStartTime, newEndTime | HH:mm | window after the change |
userName? | string | who changed it |
ROOM_CANCEL / DESK_CANCEL — booking cancelled
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
bookingDate | ISO 8601 | |
cancelReason? | string | absent = user/admin cancel; "No-show — auto-cancelled by system"; "Payment window lapsed — auto-cancelled by system" |
userName? | string |
json
{
"projectId": "68faf513142742089f0fb40f",
"userId": "66691e95a1b2c3d4e5f60719",
"timestamp": "2026-07-15T07:15:00.201Z",
"roomName": "Boardroom 7F",
"floorName": "7F",
"bookingDate": "2026-07-15T07:00:00.000Z",
"cancelReason": "No-show — auto-cancelled by system"
}ROOM_REMINDER / DESK_REMINDER — ~15 min before start
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
startTime, endTime | HH:mm | |
minutesUntilStart | number | per-asset preStartMinutes (default 15) |
userName?, userEmail? | string |
ROOM_NO_SHOW_WARNING / DESK_NO_SHOW_WARNING — no check-in, auto-cancel imminent
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
startTime, endTime | HH:mm | |
minutesUntilAutoCancel | number | time left to check in before the no-show cancel |
userName? | string |
ROOM_ENDING_SOON / DESK_ENDING_SOON — ~15 min before end
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
endTime | HH:mm | |
minutesUntilEnd | number | per-asset preEndMinutes (default 15) |
userName? | string |
VMS_INVITE_SENT — visitor invite issued
| Field | Type | Notes |
|---|---|---|
visitorName, visitorEmail | string | the invitee |
hostName, hostEmail | string | the host |
visitDate | ISO 8601 | |
startDateTime, endDateTime | string | preformatted display strings |
qrCardCode | string | the visitor's building-access QR — relay to the invitee; treat as a credential (TLS only, never log) |
visitPurpose? | string | |
licensePlate? | string |
json
{
"projectId": "68faf513142742089f0fb40f",
"userId": "66691e95a1b2c3d4e5f60720",
"timestamp": "2026-07-15T03:00:11.008Z",
"visitorName": "Jane Visitor",
"visitorEmail": "[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)
| Field | Type | Notes |
|---|---|---|
visitorName | string | |
hostAssetTitle | string | the host's unit/asset |
checkInTime | { en, th } | localized display strings |
vmsEntryType | { entryType, en, th } | how they entered (card / plate) |
licensePlateNumber | string | empty when card entry |
location? | { entryType, en, th } | entry point, when known |
webViewUrl? | string | Keyspace visit web view |
VMS_HOST_CANCELLED — host cancelled the visit
| Field | Type | Notes |
|---|---|---|
visitorName, hostName, hostEmail | string | |
startDateTime, endDateTime | string | preformatted display strings |
licensePlate? | string |
Error Handling
Error envelope
json
{
"code": "KS012",
"message": "Reservation time slot conflict",
"success": false
}Branch on the HTTP status plus code. The message text is human-readable and not a stable contract.
Validation errors (400)
Request-validation failures return KS001 with message as an array of strings, one per violated constraint:
json
{
"code": "KS001",
"message": [
"title should not be empty",
"startDateTime must be a valid ISO 8601 date string"
],
"success": false
}Error code reference
| HTTP | Code | Meaning | Handling |
|---|---|---|---|
| 400 | KS001 | Bad request / validation failed | Fix the request; surface message[] details |
| 401 | KS002 | Unauthorized (missing/expired token) | Refresh or re-authenticate, retry once |
| 403 | KS003 | Forbidden (no access to resource) | Check the project/room your credentials are scoped to |
| 404 | KS005 | Resource not found | Verify IDs |
| 409 / 410 | KS006 | State conflict (generic) | The request contradicts current resource state. Same code can arrive as 410 Gone when the target no longer exists (e.g. already-cancelled) — branch on the HTTP status, not the code alone |
| 409 | KS012 | Reservation time-slot conflict | Expected on create/update races — offer another slot |
| 429 | KS036 | Too many requests | Back off and retry with exponential delay |
| 403 | KS221 | act-as / partner members: app not authorized for this subject | Only act as / manage your OWN linked members, within your project scope; external_user_id routing needs a single-project app |
| 401 | KS223 | act-as / partner members: subject could not be resolved | Assert a valid subject/subject_type (act-as); on /partner/members/* the member row is missing — contact Keyspace |
| 500 / 503 | KS004 | Internal / upstream error | Retry with backoff (max 3); report if persistent. 503 signals a transient upstream dependency — the same retry strategy applies |
A
KSxxxcode is not 1:1 with an HTTP status.KS006spans409/410andKS004spans500/503. Switch on the HTTP status for control flow and use thecodefor 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_secretserver-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 /meetingshas no idempotency key: after a timeout, do not resend blindly. Reconcile viaGET /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: trueto preview pricing before committing;draftEndDateTimesprices several durations in one call. - Treat
409 KS012as 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/webPaymentQrto the end user promptly — the window is 5 minutes. - Pass
frontendRedirectUrlto bring the user back into your app after payment. - Poll
GET /meetings/:idto confirm the transitionunpaid → activebefore showing success.
5. Data hygiene
- Store meeting
_id+pinCode(encrypted at rest); never logpinCode, 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 —
/docsis 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.mdis 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 nowuserType: memberwith provenancesource: partner(previously it shared theact_asguest classification). Subjects created only throughact_asstayvisitor(source: federated) until an admin promotes them; members you resolve to (email / phone / employeeId match) keep their existingsource.GET /partner/members/{externalUserId}now returnssourceanduserType. 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) → newemployeeId(ProjectUseremployee id, unique per project) before creating a member, and adds the project membership when the matched user lacks one. Response gainsmatchedBy,employeeId,unit.409 KS006withdata.conflictKey(+data.linkedExternalUserId) when a key points at a different member or at one already managed under anotherexternalUserId— never a silent re-link. - New condo
unitbinding —unit: { addressNo, zoneId?, unitId? }places the member in the unit's group with the resident role; first resident = reservationowner, later =participant; move by sending another unit,unit: nullto unbind;404 KS005for an unknownaddressNo. A unit is never an identity key. - Office
employeeId-only members — valid withoutemail/phone(no login). A tenant-mappedemaildomain 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}andPUT /partner/members/{externalUserId}/credentialsfor apps that own their users, card numbers and face photos. App-token only, single-project apps, ownership keyed on YOUR user id (same rules asact_as). Access groups are fixed at app provisioning (partnerAccessGroupIds) — you never pick devices. Card numbers default to Hikvisiondecimal-reversed; faces are base64 JPEG/PNG (10 MB) and requirefaceConsentAt; 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-PUTre-activates the same member.
Version 1.9.0 (2026-08)
GET /meetingsnow expands recurring series whenfrom/toare supplied — see List Meetings. One row per occurrence overlapping your window, each carrying that occurrence's real times plusseriesMasterIdandoccurrenceEventId. Breaking for consumers that assume_idis unique in a list response — every occurrence of one series repeats the master_id; key onoccurrenceEventIdwhen present._idremains the id you act on, andGET /meetings/:idstill 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
datafields, types, and sample JSON for every webhook event type (booking + visitor), sourced from the emitting code. Includes the base fields on every event, theHH:mmwall-clock convention for booking times, and thecancelReasonvalues.
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.txtand/llms-full.txtstay 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_asdelegation 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 atmember ∩ appscope and is not refreshable. Added error codesKS221(not authorized to act as subject) andKS223(subject not resolved).
Version 1.6.0 (2026-07)
- New: visitor (VMS) webhook events —
VMS_INVITE_SENT,VMS_VISITOR_CHECKED_IN, andVMS_HOST_CANCELLEDnow deliver on the existing webhook channel (same signature, retry, and auth policy).VMS_INVITE_SENTintentionally carriesqrCardCode, 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
KSxxxcodes are not 1:1 with HTTP status —KS006spans409/410andKS004spans500/503; branch on the HTTP status, use the code for telemetry.
Version 1.5.0 (2026-07)
custom-headernow 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),bearertoken,custom-header(API key), or a short-livedjwt(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_WARNINGevents andcancelReasonvalues distinguishing user cancels from no-show and payment-lapse auto-cancels.
Version 1.2.0 (2026-07)
- Corrected the meeting lifecycle: a
checked-inbooking cannot be cancelled — it ends via check-out (organizer orpinCode) or automatically at the booking's end time. Previous doc wrongly listedchecked-in → cancelled. - New: Notifications & Automatic Status Transitions — full catalog of the booking scheduler (
pre-start,post-start-warn, no-show auto-cancel atpost-start-expire,pre-end, unpaid payment-lapse auto-cancel), per-asset timing viaconfig.meetingConfig(with-1disable sentinel), and reconciliation guidance for statuses that change without an API call. - Documented
config.meetingConfigin 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 astatus: "success"/"error"field that does not exist). - Corrected HTTP status codes: create returns
201; cancel/check-in/check-out return202. - Corrected error codes: time-slot conflict is
409 KS012(notTIME_SLOT_CONFLICT); addedKS001,KS006,KS036; documented the 400 validation shape (messageas string array). - Corrected
paymentChannelvalues:prompt-pay,credit-card,alipay,true-money,we-chat(hyphenated). - Corrected
GET /meeting-rooms:projectIdis required; removed non-existentbrandId/limit/offsetparameters (endpoint is not paginated); addedids,name,locationId,minCapacityfilters. - New: availability search on
GET /meeting-roomsviastartDateTime/endDateTime+unavailableReason. - New endpoints documented:
GET /meetings(list),GET /meetings/:id(detail),GET /desks,GET /desks/:id. - New: desk bookings via
POST /meetingswithdeskId+ either an explicitendDateTimeor a presettimePeriod. - New: explicit-window desk availability search —
GET /desks?startDateTime=…&endDateTime=…as an alternative to the presettimePeriodfilter. - 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
unavailableReasonflag) and thattimePeriodpresets 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_inin the response. - Removed the legacy example code that referenced non-existent
/auth/tokenand/auth/refreshendpoints; 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.