Keyspace Third-Party Application API Documentation
Version 1.15.0
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:
{
"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:
{
"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:
Content-Type: application/jsonRequest Body:
{
"app_id": "your_app_id_here",
"app_secret": "your_app_secret_here",
"grant_type": "app_credentials"
}Response (Success — 201 Created):
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token_expires_in": 604800
}This endpoint returns the token object directly — it is not wrapped in the standard response envelope, and it does not include a
token_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:
curl -X POST https://api.keyspace-qat.com/auth/accessToken \
-H "Content-Type: application/json" \
-d '{
"app_id": "your_app_id_here",
"app_secret": "your_app_secret_here",
"grant_type": "app_credentials"
}'2. Using the Access Token
Include the access token in the Authorization header of every API request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...3. Refresh Access Token
Endpoint: POST /auth/accessToken (same endpoint, different grant)
Request Body:
{
"refresh_token": "your_refresh_token_here",
"grant_type": "refresh_token"
}The response has the same shape as the initial token response, including a new refresh token — always store the newest pair.
Token lifecycle guidance:
- Refresh proactively: if the access token expires within the next 5 minutes, refresh before sending the request.
- If a request returns
401(KS002) or the refresh fails, re-authenticate once withapp_credentials, retry the request once, then propagate the error. - A
503withKS234means authentication is temporarily unavailable: the token could not be checked right now — it was not rejected. Retry the same request with backoff (e.g. 1 s, 2 s, 4 s); do not re-authenticate and do not discard your tokens. The refresh grant can answerKS234too; keep the refresh token and retry it. - Store tokens securely (environment variables or a secrets vault). Never write tokens or credentials to logs.
4. Act on behalf of a member (act_as) — advanced
Most integrations act purely as the app with the app_credentials token above.
If instead you want your end-users to reuse Keyspace's per-user scope
(member booking quotas, member-owned resources, per-member roles), you can mint a
member-scoped delegated token without ever giving your users a Keyspace
password — the app vouches for them.
This is a coordinated feature: your app must be provisioned by Keyspace with the right project scope first. Talk to your Keyspace contact before using it.
Endpoint: POST /auth/accessToken (same endpoint, act_as grant). The
RFC-8693 URN urn:ietf:params:oauth:grant-type:token-exchange is accepted as an
alias for act_as.
Request Body:
{
"grant_type": "act_as",
"app_id": "your_app_id",
"app_secret": "your_app_secret",
"subject": "your-own-user-id-123",
"subject_type": "external_user_id"
}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
{
"firstName": "Somchai",
"lastName": "Jaidee",
"profileName": "Chai",
"email": "somchai@example.com",
"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:
{
"code": "KS000",
"success": true,
"data": {
"memberId": "66b1f0a2c9e77a0012345678",
"externalUserId": "your-user-id-123",
"projectId": "66a0…",
"created": true,
"matchedBy": "provisioned",
"accessGroupIds": ["66c2…"],
"employeeId": "HR-0042",
"unit": {
"roomId": "66d3…",
"addressNo": "12/34",
"groupId": "66d4…",
"role": "owner"
}
}
}matchedBy is link | email | phone | employeeId | provisioned.
unit is null when the member has no unit binding; unit.role is derived
from the room reservation after the write (owner | participant).
accessGroupIds is the set the member actually holds after the call — if it is
empty, the app has no (active) partner groups yet: contact Keyspace before
relying on device access.
PUT /partner/members/{externalUserId}/credentials — set cards and face
A declarative set. Each field is independent; omit a field to leave that credential type untouched.
{
"cards": [
{ "cardNo": "2140450341", "label": "Resident card" },
{ "cardNo": "25AE947F", "cardCodeFormat": "hex" }
],
"face": "data:image/jpeg;base64,/9j/4AAQSkZJRg…",
"faceConsentAt": "2026-09-01T09:30:00.000Z"
}cards— the EXACT physical cards the member should hold. Missing ones are enrolled, absent ones are revoked, common ones are kept.[]revokes every physical card. Matching is on the decoded 4-byte card UID, so the same card submitted in two formats is one card. Max 20 per member.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:
{
"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:
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 — including a room whose cleaning time (cleanupMinutes, below) the window would run into. Rooms that are free but not bookable for that window for a configuration reason are returned with 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
{
"code": "KS000",
"success": true,
"message": "Success",
"data": [
{
"_id": "60d5ec49f1b2c72b8c8e4a1b",
"name": "Conference Room A",
"email": "conf-room-a@company.com",
"capacity": 10,
"status": "active",
"type": "normal",
"brandId": "60d5ec49f1b2c72b8c8e4a1a",
"projectId": "60d5ec49f1b2c72b8c8e4a1c",
"zoneId": "60d5ec49f1b2c72b8c8e4a1d",
"floorId": "60d5ec49f1b2c72b8c8e4a1e",
"equipmentsAvailability": {
"camera": true,
"display": true,
"mic": true,
"projector": true,
"speaker": true
},
"pictureUrls": ["https://cdn.example.com/room-a-1.jpg"],
"facilities": ["WiFi", "Whiteboard", "Video Conferencing"],
"isPublic": true,
"cleanupMinutes": 15,
"chargeRate": {
"perHour": 100,
"perDay": 600,
"amount": 100
},
"config": {
"dayConfig": {
"Mon": {
"availableTime": { "start": "09:00:00", "end": "18:00:00" },
"breakTimes": [{ "start": "12:00:00", "end": "13:00:00" }]
},
"Tue": { "availableTime": { "start": "09:00:00", "end": "18:00:00" } },
"Wed": { "availableTime": { "start": "09:00:00", "end": "18:00:00" } },
"Thu": { "availableTime": { "start": "09:00:00", "end": "18:00:00" } },
"Fri": { "availableTime": { "start": "09:00:00", "end": "17:00:00" } }
},
"start": "09:00:00",
"end": "18:00:00",
"minimumLeadTime": { "minutes": 15, "hours": 0, "days": 0 },
"maxAdvanceBookingDays": 30,
"maxDurationMinutes": 240,
"bookMultipleDayRoom": false
},
"createdAt": "2023-06-25T10:00:00.000Z",
"updatedAt": "2023-06-25T10:00:00.000Z"
}
]
}Room config fields:
| Field | Description |
|---|---|
dayConfig | Source of truth for operating hours. Per-weekday (Mon…Sun) availableTime {start, end} plus optional breakTimes[] during which booking is blocked. A missing day means the room is not bookable that day. |
start / end | Legacy overall operating hours. Kept for backward compatibility — prefer dayConfig when present. |
minimumLeadTime | Minimum advance notice for a booking: {minutes, hours, days} before start time |
maxAdvanceBookingDays | How far in the future a booking may start |
maxDurationMinutes | Maximum booking length, when configured |
bookMultipleDayRoom | Whether a single booking may span multiple days |
meetingConfig | Notification/auto-cancel timings for this asset: preStartMinutes, postStartWarnMinutes, postStartExpireMinutes (no-show auto-cancel; -1 disables), preEndMinutes — see Notifications & Automatic Status Transitions |
Times in dayConfig, start, and end are property-local wall-clock times (HH:mm:ss).
Cleaning time — cleanupMinutes. A room may reserve minutes of cleaning after every booking: cleanupMinutes, an integer 0–60 (0 = no cleaning). A booking then holds the room until its end plus the cleaning. With 15 minutes of cleaning, a 10:00–11:00 booking keeps the room until 11:15, so the next booking may start at 11:15 at the earliest — and a booking before it must end by 09:45. A room without cleanupMinutes follows the property's default, which may be none — the property default applies to desks too. The effective cleaning time can be shorter when the property's booking grid is shorter. The availability search above accounts for it: a room is left out when the window starts inside a booking's cleaning, or when its own cleaning would run into the next booking — so a room it lists as free is not refused for cleaning time (see Create Meeting).
cURL Example — rooms free tomorrow 14:00–15:00:
curl -G "https://api.keyspace-qat.com/meeting-rooms" \
-H "Authorization: Bearer {access_token}" \
--data-urlencode "projectId=60d5ec49f1b2c72b8c8e4a1c" \
--data-urlencode "status=active" \
--data-urlencode "startDateTime=2026-07-15T14:00:00+07:00" \
--data-urlencode "endDateTime=2026-07-15T15:00:00+07:00"Get Single Meeting Room
Endpoint: GET /meeting-rooms/:id
Returns one room in the same shape as the list entry, including its booking calendar for availability display.
Desks
Desks are bookable workstations. Listing and detail are below; booking a desk uses the same POST /meetings endpoint with deskId instead of meetingRoomId (see Create Meeting). Both the listing availability filter and booking take the time as either an explicit startDateTime/endDateTime window or a startDateTime + preset timePeriod — exactly one of the two forms.
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.- The property's cleaning time counts as taken here too: a desk is removed when the window starts inside a booking's cleaning, or its own cleaning would run into the next booking (desks have no
cleanupMinutesof their own — they follow the property's default; see Meeting Rooms).Omit all three time params for a plain, unfiltered desk listing.
cURL Example — desks free tomorrow morning:
curl -G "https://api.keyspace-qat.com/desks" \
-H "Authorization: Bearer {access_token}" \
--data-urlencode "projectId=60d5ec49f1b2c72b8c8e4a1c" \
--data-urlencode "status=active" \
--data-urlencode "startDateTime=2026-07-15T00:00:00+07:00" \
--data-urlencode "timePeriod=morning"Response (Success — 200 OK): paginated — includes meta.
{
"code": "KS000",
"success": true,
"message": "Success",
"meta": { "limit": 20, "offset": 0, "total": 12 },
"data": [
{
"_id": "60d5ec49f1b2c72b8c8e4b10",
"name": "Hot Desk 01",
"index": 1,
"email": "desk-01@company.com",
"status": "active",
"isPublic": true,
"brandId": "60d5ec49f1b2c72b8c8e4a1a",
"projectId": "60d5ec49f1b2c72b8c8e4a1c",
"zoneId": "60d5ec49f1b2c72b8c8e4a1d",
"floorId": "60d5ec49f1b2c72b8c8e4a1e",
"capacity": 1,
"facilities": ["Monitor", "USB-C Dock"],
"pictureUrls": [],
"config": {
"dayConfig": {
"Mon": { "availableTime": { "start": "08:00:00", "end": "18:00:00" } }
},
"minimumLeadTime": { "minutes": 0, "hours": 0, "days": 0 }
}
}
]
}Get Single Desk
Endpoint: GET /desks/:id
Returns one desk in the same shape as the list entry, including its booking calendar.
Meeting Operations
Create Meeting
Create a booking for a meeting room or a desk.
Endpoint: POST /meetings
Request Headers:
Authorization: Bearer {access_token}
Content-Type: application/jsonRequest Body (room booking):
{
"meetingRoomId": "60d5ec49f1b2c72b8c8e4a1b",
"title": "Product Strategy Meeting",
"body": "Discussing Q3 product roadmap and priorities",
"startDateTime": "2026-07-15T14:00:00+07:00",
"endDateTime": "2026-07-15T15:30:00+07:00",
"attendees": [
{ "email": "john.doe@company.com", "type": "required" },
{ "email": "jane.smith@company.com", "type": "optional" }
],
"estimatedAttendance": 5,
"paymentChannel": "prompt-pay"
}Request Body (desk booking):
Desk bookings specify the time as exactly one of endDateTime (explicit window) or timePeriod (preset block) — never both:
{
"deskId": "60d5ec49f1b2c72b8c8e4b10",
"title": "Work from HQ",
"startDateTime": "2026-07-15T09:00:00+07:00",
"endDateTime": "2026-07-15T12:30:00+07:00"
}{
"deskId": "60d5ec49f1b2c72b8c8e4b10",
"title": "Work from HQ",
"startDateTime": "2026-07-15T00:00:00+07:00",
"timePeriod": "morning"
}Request Body Fields:
| Field | Type | Required | Description |
|---|---|---|---|
meetingRoomId | string | Yes* | Meeting room to book (*exactly one of meetingRoomId / deskId) |
deskId | string | Yes* | Desk to book |
title | string | Yes | Booking title |
body | string | No | Description / agenda |
startDateTime | string | Yes | Start (ISO 8601 with timezone offset) |
endDateTime | string | Yes** | End (**desk bookings: provide exactly one of endDateTime / timePeriod) |
timePeriod | string | No | Desk bookings only: morning, afternoon, allDay — mutually exclusive with endDateTime |
attendees | array | No | Attendee list |
attendees[].email | string | Yes | Attendee email |
attendees[].id | string | No | Keyspace user ID, if known |
attendees[].type | string | No | required or optional (default required) |
estimatedAttendance | number | No | Expected participant count |
paymentChannel | string | No | prompt-pay (default), credit-card, alipay, true-money, we-chat |
discountCode | string | No | Discount / promo code |
organizerId | string | No | Book on behalf of this member (back office). Caller needs MEETING_UPDATE on the asset's project (403 otherwise); the member must belong to it (400). The member becomes the organizer, the caller is recorded as bookedByUserId, nothing is minted at create |
paymentDueMinutes | number | No | Back-office bookings only (400 otherwise): minutes the organizer has to pay. Absent ⇒ the project's backofficePaymentDueMinutes, else 1440. Over 10080 or past the meeting start is clamped, never rejected |
frontendRedirectUrl | string | No | URL the payer is redirected to after completing web payment (use your app's return/deep-link URL) |
draft | boolean | No | true = price preview only, nothing is created (default false) |
draftEndDateTimes | array | No | With draft: true: preview prices for several candidate end times in one call |
Response (Success — 201 Created):
Free booking (no charge):
{
"code": "KS000",
"success": true,
"message": "Success",
"data": {
"_id": "60d5ec49f1b2c72b8c8e4a50",
"meetingRoomId": "60d5ec49f1b2c72b8c8e4a1b",
"meetingRoomName": "Conference Room A",
"title": "Product Strategy Meeting",
"body": "Discussing Q3 product roadmap",
"status": "active",
"startDateTime": "2026-07-15T14:00:00+07:00",
"endDateTime": "2026-07-15T15:30:00+07:00",
"pinCode": "123456",
"organizer": {
"userId": "60d5ec49f1b2c72b8c8e4a2a",
"email": "organizer@company.com",
"name": "John Organizer",
"displayName": "John Organizer"
},
"attendees": [
{
"userId": "60d5ec49f1b2c72b8c8e4a2b",
"email": "john.doe@company.com",
"name": "John Doe",
"type": "required",
"responseStatus": "none"
}
],
"createdAt": "2026-07-10T10:00:00.000Z",
"updatedAt": "2026-07-10T10:00:00.000Z"
}
}Paid booking (charge applies):
{
"code": "KS000",
"success": true,
"message": "Success",
"data": {
"_id": "60d5ec49f1b2c72b8c8e4a50",
"meetingRoomId": "60d5ec49f1b2c72b8c8e4a1b",
"title": "Product Strategy Meeting",
"status": "unpaid",
"startDateTime": "2026-07-15T14:00:00+07:00",
"endDateTime": "2026-07-15T15:30:00+07:00",
"pinCode": "123456",
"organizer": {
"userId": "60d5ec49f1b2c72b8c8e4a2a",
"email": "organizer@company.com",
"name": "John Organizer"
},
"payment": {
"paymentId": "pay_abc123xyz",
"invoiceNo": "INV-2026-001234",
"amount": 500,
"summary": {
"subtotal": 500,
"total": 450,
"discounts": [
{ "code": "EARLY10", "value": "50", "name": "Early Bird Discount" }
]
},
"webPaymentUrl": "https://payment.example.com/pay/abc123xyz",
"webPaymentQr": "data:image/png;base64,iVBORw0KGgoAAAANS..."
},
"billingSummaries": [
{
"payment_id": "pay_abc123xyz",
"amount": 500,
"total": 450,
"invoice_no": "INV-2026-001234",
"status": "unpaid",
"expiredAt": "2026-07-10T10:05:00.000Z"
}
],
"createdAt": "2026-07-10T10:00:00.000Z"
}
}Important Notes:
- Booking must satisfy the room's
config: within operating hours (dayConfig), 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. A back-office booking (created withorganizerIdby a caller holdingMEETING_UPDATE) mints no payment at create: the response has nowebPaymentUrl,billingSummaries[0]is akind: "quote"entry withoutpayment_id, and the booking is held untilpaymentDueAt(paymentDueMinutes→ the project'sbackofficePaymentDueMinutes→ 1440 minutes, clamped to 7 days and to the meeting start) — the organizer pays it later through the Keyspace app, which appends the real payment entry. - Set
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. - Cleaning time counts as taken. When the room has
cleanupMinutes(or the property reserves cleaning time for its rooms and desks), a booking may not start before the previous booking's end plus the cleaning, and must end that long before the next booking starts. Such a booking is refused with the same409code an overlapping booking gets; the message says when the cleaning ends — "The meeting room is being cleaned until 11:15. Start at 11:15 or later." — or how early to end — "The meeting room needs 15 minutes of cleaning before the next booking at 10:00. End by 09:45 or earlier." Offer the next free start. - A
409states why it refused, and the reason is actionable. The refusal carries its own code: the room is unavailable (MEETING_006), the desk is unavailable (MEETING_044), the start has passed (MEETING_045), the organizer is at their booking quota (MEETING_046), the booking is longer than the space allows (MEETING_047), the date is a holiday (MEETING_048), or a recurring series cannot hold the space (MEETING_050). Route on the code, show the message — do not assume a409means "try another room", because for most of these it does not. - A
403withMEETING_049means the organizer is blocked from booking. An admin has blacklisted them. It is an authorization refusal, not a slot or room problem, so it never belongs in your "offer another slot" branch: show the message and direct the user to the building admin — no other room, time or retry will succeed until the block is lifted. - A
403withKS003on create means the room or desk is private (isPublic: false) and the organizer is not a member of its project. Private assets are for the project's own people. Offer a public room or desk, or ask the building admin to add the user to the project. - A
500withKS004is not a booking verdict. It means an internal fault (dropped connection, write conflict, bug); the room, desk, time and quota were all fine. Retry with backoff per the error code reference rather than steering the user to a different space. Because there is no idempotency key, reconcile withGET /meetingsbefore retrying — see the next note. - 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:
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). - That includes the cleaning time (see Meeting Rooms): a move or an extension that runs into it returns the same
409 KS012, with a message that says when the cleaning ends or how early to end. An edit that keeps the time (title, body, attendees) is never refused for cleaning time, and neither is a booking that sat next to another before the cleaning time was set — unless the change moves it closer.draftInfo[].isAvailableon adraft: trueupdate already accounts for the cleaning time. - A change to
startDateTimeorendDateTimeby an organizer an admin has blocked from booking returns403MEETING_049, the same refusal as create. An edit that keeps the time (title, body, attendees) still succeeds.
Cancel Meeting
Endpoint: POST /meetings/:id/cancel
Request Body Fields (optional):
| Field | Type | Description |
|---|---|---|
pinCode | string | Required when the caller is not the organizer |
paymentId | string | Payment to void when cancelling an unpaid booking |
Response (Success — 202 Accepted): the meeting with status: "cancelled" and an appended histories entry.
Important Notes:
- Only
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:
"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:
POST /your/callback/path HTTP/1.1
Content-Type: application/json
User-Agent: KeyspaceWebhooks/1.0
X-Keyspace-Event: ROOM_CANCEL
X-Keyspace-Delivery: whk_7f8a1f4e-1234-4b3c-9d2e-abcdef012345
X-Keyspace-Timestamp: 1752562800000
X-Keyspace-Signature: sha256=3f5c…e2a1{
"id": "whk_7f8a1f4e-1234-4b3c-9d2e-abcdef012345",
"type": "ROOM_CANCEL",
"version": 1,
"occurredAt": "2026-07-15T07:15:00.000Z",
"projectId": "60d5ec49f1b2c72b8c8e4a1c",
"data": {
"roomName": "Conference Room A",
"floorName": "Floor 5",
"bookingDate": "2026-07-15T07:00:00.000Z",
"cancelReason": "No-show — auto-cancelled by system",
"userName": "Somchai",
"userEmail": "somchai@example.com"
}
}id(=X-Keyspace-Delivery) is unique per delivery — use it to de-duplicate retries on your side.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:
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:
const jwt = require("jsonwebtoken");
const claims = jwt.verify(bearerToken, sharedSecret, {
algorithms: ["HS256"],
issuer: "keyspace",
audience: yourProjectId,
});Pick signature-only unless your gateway genuinely rejects unauthenticated
POSTs — verifying the signature is simpler and needs nothing from us.
Responding, retries, and the failure policy
- Respond 2xx within 10 seconds to acknowledge. Do heavy work async — ack first, process after.
- Anything else (non-2xx, timeout, connection error) is retried up to 5 attempts with exponential backoff starting at ~1 minute.
- After 20 consecutive deliveries that exhaust all retries, the webhook is auto-disabled to protect the queue — ask your Keyspace contact to re-enable it once your endpoint is healthy (re-enabling resets the counter).
- Ordering is not guaranteed under retries — use
occurredAt(and re-fetching) rather than arrival order.
Event catalog
type | Fires | Key data fields |
|---|---|---|
ROOM_BOOK / DESK_BOOK | booking confirmed | roomName/deskName, bookingDate, startTime, endTime, userName, userEmail |
ROOM_MODIFY / DESK_MODIFY | booking changed | oldStartTime, oldEndTime, newStartTime, newEndTime |
ROOM_CANCEL / DESK_CANCEL | booking cancelled — user action or system auto-cancel | bookingDate, cancelReason (absent = user/admin cancel; "No-show — auto-cancelled by system"; "Payment window lapsed — auto-cancelled by system"; "Unpaid — back-office hold expired") |
ROOM_REMINDER / DESK_REMINDER | ~15 min before start | startTime, endTime, minutesUntilStart |
ROOM_NO_SHOW_WARNING / DESK_NO_SHOW_WARNING | ~10 min after start, nobody checked in | startTime, endTime, minutesUntilAutoCancel |
ROOM_ENDING_SOON / DESK_ENDING_SOON | ~15 min before end | endTime, minutesUntilEnd |
ROOM_PAYMENT_DUE / DESK_PAYMENT_DUE | a back-office booking (bookedVia: "backoffice") is held unpaid — sent at create and once more at ~75 % of the hold | bookingDate, startTime, endTime, amount, currency, paymentDueAt, paymentDueTime, minutesUntilDue |
Timings follow the asset's config.meetingConfig (see the previous section) —
the ~values above are the defaults.
Visitor events (VMS)
Delivered on the same channel for projects that use the Visitor Management
System. Subscribe to these 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 (null = no end time), qrCardCode (access QR — relay to the visitor, treat as a credential), licensePlate?, visitPurpose? |
VMS_VISITOR_CHECKED_IN | a visitor checks in (card or license plate) | visitorName, hostAssetTitle, checkInTime ({ en, th }), vmsEntryType, licensePlateNumber, location?, webViewUrl? |
VMS_HOST_CANCELLED | the host cancels the visit | visitorName, hostName, hostEmail, startDateTime, endDateTime (null = no end time), licensePlate? |
Only these three visitor 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 |
{
"projectId": "68faf513142742089f0fb40f",
"userId": "66691e95a1b2c3d4e5f60719",
"timestamp": "2026-07-15T06:55:03.412Z",
"roomName": "Boardroom 7F",
"floorName": "7F",
"bookingDate": "2026-07-15T07:00:00.000Z",
"startTime": "14:00",
"endTime": "15:00",
"attendees": ["Anan P.", "Beam K."],
"userName": "Anan P.",
"userEmail": "anan@partner.co"
}ROOM_MODIFY / DESK_MODIFY — booking changed
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
oldStartTime, oldEndTime | HH:mm | window before the change |
newStartTime, newEndTime | HH:mm | window after the change |
userName? | string | who changed it |
ROOM_CANCEL / DESK_CANCEL — booking cancelled
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
bookingDate | ISO 8601 | |
cancelReason? | string | absent = user/admin cancel; "No-show — auto-cancelled by system"; "Payment window lapsed — auto-cancelled by system"; "Unpaid — back-office hold expired" |
userName? | string |
{
"projectId": "68faf513142742089f0fb40f",
"userId": "66691e95a1b2c3d4e5f60719",
"timestamp": "2026-07-15T07:15:00.201Z",
"roomName": "Boardroom 7F",
"floorName": "7F",
"bookingDate": "2026-07-15T07:00:00.000Z",
"cancelReason": "No-show — auto-cancelled by system"
}ROOM_REMINDER / DESK_REMINDER — ~15 min before start
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
startTime, endTime | HH:mm | |
minutesUntilStart | number | per-asset preStartMinutes (default 15) |
userName?, userEmail? | string |
ROOM_NO_SHOW_WARNING / DESK_NO_SHOW_WARNING — no check-in, auto-cancel imminent
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
startTime, endTime | HH:mm | |
minutesUntilAutoCancel | number | time left to check in before the no-show cancel |
userName? | string |
ROOM_ENDING_SOON / DESK_ENDING_SOON — ~15 min before end
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
endTime | HH:mm | |
minutesUntilEnd | number | per-asset preEndMinutes (default 15) |
userName? | string |
ROOM_PAYMENT_DUE / DESK_PAYMENT_DUE — back-office booking awaiting the resident's payment
Only for bookedVia: "backoffice" bookings (staff booked FOR the resident; the resident pays from their own device). Sent at create and once more at ~75 % of the hold; when the hold lapses the booking is cancelled with cancelReason: "Unpaid — back-office hold expired".
| Field | Type | Notes |
|---|---|---|
roomName | deskName, floorName | string | |
bookingDate | ISO 8601 | |
startTime, endTime | HH:mm | |
amount | number | the amount still due (the staff quote until the resident mints a payment) |
currency | string | THB |
paymentDueAt | ISO 8601 | the hold deadline — same instant as GET /meetings/{id} paymentDueAt |
paymentDueTime | string | paymentDueAt pre-formatted (dd MMM yyyy HH:mm) |
minutesUntilDue | number | at send time |
userName?, userEmail? | string | the resident |
VMS_INVITE_SENT — visitor invite issued
| Field | Type | Notes |
|---|---|---|
visitorName, visitorEmail | string | the invitee |
hostName, hostEmail | string | the host |
visitDate | ISO 8601 | |
startDateTime | string | preformatted display string (site local time) |
endDateTime | string | null | preformatted display string; null when the invite has no end time: the visitor may arrive any time on the start day, and the invite expires at the end of that day |
qrCardCode | string | the visitor's building-access QR — relay to the invitee; treat as a credential (TLS only, never log) |
visitPurpose? | string | |
licensePlate? | string |
{
"projectId": "68faf513142742089f0fb40f",
"userId": "66691e95a1b2c3d4e5f60720",
"timestamp": "2026-07-15T03:00:11.008Z",
"visitorName": "Jane Visitor",
"visitorEmail": "jane@guest.co",
"hostName": "Anan P.",
"hostEmail": "anan@partner.co",
"visitDate": "2026-07-16T02:00:00.000Z",
"startDateTime": "16 Jul 2026 09:00",
"endDateTime": "16 Jul 2026 12:00",
"qrCardCode": "QR-ACCESS-8F3A21",
"licensePlate": "กก 1234"
}An invite with no end time carries "endDateTime": null. Read it as "valid all day on
the start date": treat a missing end as the end of that day. Do not reject the event.
VMS_VISITOR_CHECKED_IN — visitor checked in (card or license plate)
| Field | Type | Notes |
|---|---|---|
visitorName | string | |
hostAssetTitle | string | the host's unit/asset |
checkInTime | { en, th } | localized display strings |
vmsEntryType | { entryType, en, th } | how they entered (card / plate) |
licensePlateNumber | string | empty when card entry |
location? | { entryType, en, th } | entry point, when known |
webViewUrl? | string | Keyspace visit web view |
VMS_HOST_CANCELLED — host cancelled the visit
| Field | Type | Notes |
|---|---|---|
visitorName, hostName, hostEmail | string | |
startDateTime | string | preformatted display string (site local time) |
endDateTime | string | null | preformatted display string; null when the invite had no end time |
licensePlate? | string |
Error Handling
Error envelope
{
"code": "KS012",
"message": "Reservation time slot conflict",
"success": false
}Branch on the HTTP status plus code. The message text is human-readable and not a stable contract.
Validation errors (400)
Request-validation failures return KS001 with message as an array of strings, one per violated constraint:
{
"code": "KS001",
"message": [
"title should not be empty",
"startDateTime must be a valid ISO 8601 date string"
],
"success": false
}Error code reference
| HTTP | Code | Meaning | Handling |
|---|---|---|---|
| 400 | KS001 | Bad request / validation failed | Fix the request; surface message[] details |
| 401 | KS002 | Unauthorized (missing/expired token) | Refresh or re-authenticate, retry once |
| 403 | KS003 | Forbidden (no access to resource) | Check the project/room your credentials are scoped to |
| 404 | KS005 | Resource not found | Verify IDs |
| 409 / 410 | KS006 | State conflict (generic) | The request contradicts current resource state. Same code can arrive as 410 Gone when the target no longer exists (e.g. already-cancelled) — branch on the HTTP status, not the code alone |
| 409 | KS012 | Reservation time-slot conflict | Expected on create/update races, or when the booking runs into the cleaning time — offer another slot |
| 429 | KS036 | Too many requests | Back off and retry with exponential delay |
| 403 | KS221 | act-as / partner members: app not authorized for this subject | Only act as / manage your OWN linked members, within your project scope; external_user_id routing needs a single-project app |
| 401 | KS223 | act-as / partner members: subject could not be resolved | Assert a valid subject/subject_type (act-as); on /partner/members/* the member row is missing — contact Keyspace |
| 500 / 503 | KS004 | Internal / upstream error | Retry with backoff (max 3); report if persistent. 503 signals a transient upstream dependency — the same retry strategy applies |
| 503 | KS234 | Authentication temporarily unavailable (the token could not be checked — it was not rejected) | Retry with backoff; do not re-authenticate or discard tokens |
A
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
const BASE_URL = process.env.KEYSPACE_BASE_URL; // e.g. https://api.keyspace-qat.com
class KeyspaceClient {
constructor(appId, appSecret) {
this.appId = appId;
this.appSecret = appSecret;
this.accessToken = null;
this.refreshToken = null;
this.expiresAt = 0;
}
async authenticate() {
const res = await fetch(`${BASE_URL}/auth/accessToken`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
app_id: this.appId,
app_secret: this.appSecret,
grant_type: "app_credentials",
}),
});
if (!res.ok) throw new Error(`auth failed: ${res.status}`);
this.storeTokens(await res.json());
}
async refresh() {
const res = await fetch(`${BASE_URL}/auth/accessToken`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
refresh_token: this.refreshToken,
grant_type: "refresh_token",
}),
});
if (!res.ok) return this.authenticate(); // refresh expired → full re-auth
this.storeTokens(await res.json());
}
storeTokens({ access_token, refresh_token, expires_in }) {
this.accessToken = access_token;
this.refreshToken = refresh_token;
this.expiresAt = Date.now() + expires_in * 1000;
}
async request(path, options = {}) {
if (Date.now() >= this.expiresAt - 300_000) await this.refresh(); // 5-min buffer
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${this.accessToken}`,
},
});
return res;
}
}
const client = new KeyspaceClient(
process.env.KEYSPACE_APP_ID,
process.env.KEYSPACE_APP_SECRET,
);
// 1. Authenticate
await client.authenticate();
// 2. Find rooms free for the requested slot
const slot = {
start: "2026-07-15T10:00:00+07:00",
end: "2026-07-15T10:30:00+07:00",
};
const roomsRes = await client.request(
`/meeting-rooms?projectId=${PROJECT_ID}&status=active` +
`&startDateTime=${encodeURIComponent(slot.start)}` +
`&endDateTime=${encodeURIComponent(slot.end)}`,
);
const rooms = (await roomsRes.json()).data.filter((r) => !r.unavailableReason);
// 3. Create the booking
const createRes = await client.request("/meetings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
meetingRoomId: rooms[0]._id,
title: "Team Standup",
startDateTime: slot.start,
endDateTime: slot.end,
attendees: [{ email: "team@company.com", type: "required" }],
frontendRedirectUrl: "https://yourapp.example.com/booking/return",
}),
});
if (createRes.status === 409) {
// KS012 — someone took the slot between search and create; offer another slot
throw new Error("slot taken, pick another");
}
const meeting = (await createRes.json()).data;
// 4. Paid room → hand the payment URL to the user, then poll for confirmation
if (meeting.status === "unpaid") {
showPaymentPage(meeting.payment.webPaymentUrl); // human flow, 5-minute window
await pollUntil(async () => {
const d = (await (await client.request(`/meetings/${meeting._id}`)).json()).data;
return d.status !== "unpaid";
});
}
// 5. On meeting day — check in (opens 15 min before start)
await client.request(`/meetings/${meeting._id}/check-in`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinCode: meeting.pinCode }),
});
// 6. Check out when done (irreversible)
await client.request(`/meetings/${meeting._id}/check-out`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pinCode: meeting.pinCode }),
});Changelog
Version 1.15.0 (2026-10)
- Visitor invites may have no end time (§Per-event payload reference):
VMS_INVITE_SENTandVMS_HOST_CANCELLEDcan now carry"endDateTime": null. A visitor invite needs only a start time. Without an end, it is valid for the whole start day and expires at the end of that day. Invites with an end time are unchanged. If your listener requiresendDateTimeto be a string, acceptnull. No other field changes.
Version 1.14.0 (2026-10)
- New
503KS234— authentication temporarily unavailable (§Error code reference) — additive. Every authenticated endpoint, andPOST /auth/accessTokenwhen a rotated refresh token is presented again, can now answer503withKS234when the credential cannot be checked right now. The credential was not rejected: retry with backoff and do not re-authenticate or discard your tokens. No existing response changed: a missing, expired or revoked token is still401KS002.
Version 1.13.1 (2026-10)
- Desk availability search counts the cleaning time (§Desks) — the
GET /desksavailability search (startDateTimeplusendDateTimeortimePeriod) now also leaves out a desk whose window starts inside a booking's cleaning time, or whose own cleaning would run into the next booking, using the property's default cleaning time (desks have nocleanupMinutesof their own). Until now a desk could be listed as free and then refused byPOST /meetingsfor its cleaning time. Desks without cleaning time — and pooled desks — behave exactly as before. TheGET /meeting-roomsdescription in the OpenAPI spec now states the same rule for rooms (behaviour unchanged since 1.13.0).
Version 1.13.0 (2026-10)
- Cleaning time between bookings (§Meeting Rooms, §Create Meeting, §Update/Extend Meeting) — a room may reserve minutes of cleaning after every booking: meeting-room reads (
GET /meeting-rooms,GET /meeting-rooms/{id}) may carrycleanupMinutes, an integer 0–60 (0= none; absent = the property's default, which may be none and also applies to desks).POST /meetingsandPATCH /meetings/{id}now refuse a booking that starts inside the previous booking's cleaning, or whose own cleaning runs into the next booking, with the same409code an overlapping booking gets and a message that says when the cleaning ends ("The meeting room is being cleaned until 11:15. Start at 11:15 or later.") or how early to end. An edit that keeps the time is never refused for it, anddraftInfo[].isAvailableaccounts for it. TheGET /meeting-roomsavailability search (startDateTime+endDateTime) leaves out a room whose cleaning time the window would run into. Rooms and desks without cleaning time behave exactly as before.
Version 1.12.2 (2026-09)
- Meeting-room outline (§Meeting Rooms) — meeting-room reads (
GET /meeting-rooms,GET /meeting-rooms/{id}) may carrypolygon: an array of{ xPct, yPct }vertices (0–100, percent of the floor-plan image), in drawing order and implicitly closed. Absent,nullor[]means the room has no outline. Additive; existing fields are unchanged.
Version 1.12.1 (2026-09)
- Private rooms and desks are for their project's people (§Create Meeting) —
POST /meetingsfor a room or desk withisPublic: falsenow returns403KS003when the organizer is not a member of the asset's project. Nothing is booked and the organizer is not added to the project. The same rule applies to a booking throughsessionIdon such an asset. Public rooms and desks are unchanged: anyone logged in can book them. An app token holding a role on the project, and brand staff, still book private assets.
Version 1.12.0 (2026-09)
- Book on behalf of a member (back office) (§Create Meeting) —
POST /meetingsacceptsorganizerId. The caller must holdMEETING_UPDATEon the asset's project (403otherwise) and the member must belong to it (400). The member is the organizer, the caller is recorded asbookedByUserId(withbookedViamarking the booking as back-office), promo pricing is evaluated in the member's scope, and nothing is minted at create —billingSummaries[0]is akind: "quote"entry the member pays later from their own device. - Payment hold window for back-office bookings — such a booking is held until the new response field
paymentDueAt: requestpaymentDueMinutes(back-office only,400otherwise) → the project'sbackofficePaymentDueMinutes→ 1440 minutes; over 10080 minutes or past the meeting start is clamped, never rejected.billingSummaries[].expiredAtequals it and the booking auto-cancels when it lapses. Self-service bookings keep the 5-minute window.
Version 1.11.3 (2026-09)
- A blocked organizer is now actually refused with
403MEETING_049onPOST /meetingsand onPATCH /meetings/:idwhen the change movesstartDateTimeorendDateTime(§Update/Extend Meeting). 1.11.2 documented the code, but these routes did not yet return it: an organizer an admin had blocked from meeting rooms could still book and reschedule. The refusal is checked before every booking-window rule, so a blocked organizer getsMEETING_049even when the slot would also fail another rule. An edit that keeps the time (title, body, attendees) still succeeds, and bookings made before the block are not cancelled. Desk bookings are unaffected.PATCH /meetings/{id}now lists the403in the OpenAPI spec. If you already routeMEETING_049to a "contact your building admin" message, nothing changes on your side.
Version 1.11.2 (2026-09)
POST /meetingsnow answers500 KS004for internal faults (§Create Meeting) — a dropped database connection, a write conflict, or a bug used to surface as409withMEETING_006"the meeting is not available (may be due to the meeting room currently being unavailable)". That told you to offer another room, which never helped. These faults now return500withKS004and follow the documented retry-with-backoff contract. This fix moved no refusal's status — the one new refusal that is not a409isMEETING_049, a403(next entry).- Booking refusals name their real reason — one
409used to carryMEETING_006for unrelated conditions. A refusal now carries its own code and sentence:MEETING_044(desk unavailable — a desk booking is no longer described as a room problem),MEETING_045(start already passed),MEETING_046(booking quota reached),MEETING_047(longer than the space's maximum),MEETING_048(closed for a holiday),MEETING_049(organizer blocked by an admin —403, not409),MEETING_050(recurring series cannot hold the space).MEETING_006remains, narrowed to what it says: the room itself is unavailable. Codes are additive and no refusal a released version returned changed status, so existing handling keeps working — but a client that matched on theMEETING_006string to detect these cases must now match the new codes. The one new code that is not a409isMEETING_049, which answers403: a blocked organizer is an authorization refusal, not a slot problem, so route it to a "contact your building admin" message rather than to your "offer another slot" branch.
Version 1.11.1 (2026-09)
- Docs: per-module guides —
/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.
Members & Credentials
Version 1.12.0 · For third-party apps that are the system of record for their users — a condo / residence app, an office or HR app — and want Keyspace to enrol those people on the building's…
OpenAPI reference
Every operation of the Keyspace Third-Party Application API (version 1.15.0) with request and response schemas, code samples and a request playground.