Keyspace Meeting Rooms & Desks — Integration Guide
Version 1.15.0 · The narrative guide for third-party apps that book Keyspace meeting rooms and desks. Platform basics (credentials, token, envelope, webhooks) are on the getting-started page.
This guide is the narrative — how the pieces fit and the journey a booking
travels. For field-by-field endpoint detail see the
full API reference; for a machine-readable contract see the
OpenAPI spec. To scaffold a client quickly, hand your coding
agent the integration prompt and this environment's
/docs/openapi.json.
1. Before you start
Keyspace issues you, per environment:
app_id+app_secret— your OAuth client credentials (server-side only)- a default
projectId— the property whose rooms/desks you may book
| Environment | Base URL | Use for |
|---|---|---|
| Development | https://api.keyspace-dev.com | Early build + smoke tests |
| UAT | https://api.keyspace-qat.com | Pre-production sign-off |
| Production | https://api.keyspace.tech | Live |
Credentials are not shared across environments. Each environment also serves its own copy of this guide, the reference, and the OpenAPI spec — always read the docs from the environment you are integrating against.
2. The five-minute mental model
- Resources. A
projectId(property) contains meeting rooms and desks. Both are booked through the samePOST /meetingsendpoint — a room booking passesmeetingRoomId, a desk booking passesdeskId. - Envelope. Every response except the token endpoint is wrapped:
{ code, success, message, data, meta? }.code: "KS000"means success; any otherKSxxxis an error. Unwrapdata; readmetaon paginated lists. - Lifecycle. A booking moves
unpaid → active → checked-in → done, withcancelledreachable fromunpaidandactiveonly — achecked-inbooking cannot be cancelled; it ends via check-out (organizer orpinCode) or automatically at the booking's end time.rejectedis a terminal decline. Free rooms skipunpaidand land onactiveimmediately. - Bookings change state on their own. Timers run against every booking — payment window, no-show auto-cancel, close at end time. Section 3 has the state diagram, every timer and every refusal code.
- Webhooks push, polling reconciles. Register a listener server (see
section 7) and Keyspace POSTs signed lifecycle events to your backend —
bookings, changes, cancels (including the automatic ones), reminders. Keep
GET /meetings/:idpolling as the reconciliation fallback (payment confirmation, missed deliveries). - No availability endpoint per se. You search rooms/desks for a time window and get back the ones that are free — see step 3.
3. The booking lifecycle
status on GET /meetings/:id is the single source of truth for a booking. It
is in exactly one of six states, and three things move it: your calls, the
clock (timers configured per room/desk) and, for calendar-synced rooms, the
organizer's own calendar. Statuses change without any call from you — re-fetch
before you show a state-dependent action.
3.1 States — what each means and what you may call
| Status | Meaning | Allowed calls | Leaves via |
|---|---|---|---|
unpaid | Paid room (or a paid extension): the slot is held while the user pays through payment.webPaymentUrl / webPaymentQr | cancel | payment settled → active; cancel, or the 5-minute payment window lapsing → cancelled |
active | Confirmed, nobody checked in yet | PATCH, cancel, check-in (inside the window) | check-in → checked-in; cancel or no-show auto-cancel → cancelled; end time → done |
checked-in | Someone checked in; the room and its devices are live | PATCH (end time only), check-out | check-out, or end time → done |
done | Ended — checked out, or closed by the clock | — | terminal |
cancelled | Cancelled by a user, an admin, your app, or the system (then cancelReason is set) | — | terminal |
rejected | Came in through a synced calendar (an Outlook/Google invite to the room) and Keyspace declined it | — | terminal; never produced through this API |
Calling out of state is refused, not ignored:
| Call | State | Response |
|---|---|---|
PATCH /meetings/:id | unpaid | 422 "Cannot edit unpaid meeting" — pay or cancel first |
PATCH moving startDateTime | checked-in | 409 "Start time of a checked-in meeting cannot be modified." — the end may still be extended |
PATCH | cancelled | KS072 "The meeting has already cancelled" |
POST /meetings/:id/cancel | checked-in, done, cancelled | 409 MEETING_011 — a started booking ends by check-out, never by cancel |
POST /meetings/:id/check-in | anything but active inside the window | see 3.4 |
3.2 Where a new booking starts
- Free room or desk →
activeon the201. Paid room →unpaidwith apaymentobject; it becomesactivewhen the payment settles (pollGET /meetings/:id, or wait forROOM_BOOK/DESK_BOOK). - Rooms/desks configured with
postStartExpireMinutes: 0("check-in not required"): a booking created inside the check-in window ischecked-inon the201; otherwise Keyspace checks the organizer in at the start time itself (history entrysource: "system").
3.3 The clock — timed transitions
Timers are per room/desk: read config.meetingConfig on GET /meeting-rooms /
GET /desks (defaults below; absent = defaults; -1 disables that tick).
| When (default) | What happens | Webhook |
|---|---|---|
booking + 5 min, still unpaid | auto-cancel, cancelReason: "Payment window lapsed — auto-cancelled by system" | ROOM_/DESK_CANCEL |
back-office booking (bookedVia: "backoffice"): paymentDueAt (default 24 h, clamped to the start) | the resident is nudged at create and once more at ~75 % of the hold, carrying amount, paymentDueTime, minutesUntilDue; then auto-cancel, cancelReason: "Unpaid — back-office hold expired" | ROOM_/DESK_PAYMENT_DUE, then _CANCEL |
start − preStartMinutes (15) | check-in window opens; reminder to participants | _REMINDER |
start + postStartWarnMinutes (10), nobody checked in | last warning, carries minutesUntilAutoCancel | _NO_SHOW_WARNING |
start + postStartExpireMinutes (15), nobody checked in | auto-cancel, cancelReason: "No-show — auto-cancelled by system" | _CANCEL |
end − preEndMinutes (15) | ending soon — extend with PATCH if the room is free | _ENDING_SOON |
| end time | active / checked-in → done | none |
postStartExpireMinutes: -1— no warning and no auto-cancel: an unattended booking staysactiveuntil its end time, then closes todone.- All-day bookings are never no-show-cancelled.
- There is no webhook for check-in, check-out or
done— pollGET /meetings/:idfor those. - A recurring series is one booking that rolls: at each occurrence's end it moves
on to the next occurrence and returns to
active; it becomesdoneonly after the last occurrence.
3.4 Check-in
Who may check in: the organizer, a listed attendee (their own or an act_as
token), or anyone who sends the booking's pinCode. When: from
start − preStartMinutes until the booking closes; with preStartMinutes: -1,
any time on the booking's day. Refusals, in the order Keyspace evaluates them:
| Situation | HTTP | Code |
|---|---|---|
| booking is cancelled | 404 | MEETING_043 |
| booking already ended | 409 | MEETING_004 |
still unpaid | 422 | MEETING_005 |
| too early — the message says how many minutes before start the window opens | 409 | MEETING_040 |
wrong day (assets with preStartMinutes: -1) | 409 | MEETING_041 |
wrong pinCode | 404 | MEETING_042 |
| PIN locked after repeated wrong attempts — stays locked until the organizer sets a new PIN | 429 | MEETING_039 |
caller is neither organizer nor attendee and sent no pinCode | 404 | MEETING_003 |
Route MEETING_040 / MEETING_041 to "come back later" copy; they are not
"room unavailable" (MEETING_006), and no other room will help.
3.5 Check-out and early end
POST /meetings/:id/check-out works from checked-in only: endDateTime is
rewritten to the actual end (never before the start, never after the booked
end), the room and its devices are released, and the status becomes done.
Irreversible. A booking nobody checks out of closes itself at its booked end.
3.6 Calendar-synced rooms — edits from Outlook or Google
For rooms synced to Microsoft 365 or Google Workspace the organizer can also change the booking from their calendar. Keyspace applies these rules:
- Move, before check-in — applied,
pinCodeunchanged (ROOM_MODIFY). If the new slot breaks a booking rule the room declines the invite, the booking iscancelledwith that rule as the reason, and the old PIN stops working. - Move, while
checked-in— the start change is ignored; the end is extended if the room is free, otherwise refused. Never cancelled. - Delete, before check-in —
cancelled; room released, payment and PIN voided (ROOM_CANCEL). - Delete, while
checked-in— ends now:donewith the actual end time. The meeting happened, so it is notcancelled. - Room copy removed / room declined while the organizer's copy still stands —
cancelledwith an explicit reason.
3.7 Reading the history
histories[] on the booking is the audit trail: action (create, pay,
payment-timeout, update, cancel-updating, cancel, check-in,
check-out, rejected), at, source (user, partner, system,
ms_graph, google_calendar) with sourceDetail, reason on cancels and
declines, statusBeforeUpdate, and the old/new times on a move. Use it to tell
your user who changed a booking and why.
4. Step by step
Step 1 — Authenticate
POST /auth/accessToken with { app_id, app_secret, grant_type: "app_credentials" }.
You get back access_token, refresh_token, and their lifetimes in seconds.
- Send the token on every call as
Authorization: Bearer <access_token>. - Refresh proactively: when the access token is within ~5 minutes of expiry,
call the same endpoint with
{ refresh_token, grant_type: "refresh_token" }and store the new pair it returns. - Recover once: if a call still returns
401(KS002), refresh → retry once → full re-auth → then surface the error. - Retry, don't re-auth, on
503KS234: authentication is temporarily unavailable — the token was not rejected. Retry with backoff and keep your tokens. - Do not hardcode token lifetimes — they are environment configuration; read
expires_infrom the response.
Authenticate once per server process, not per request.
Advanced — act on behalf of a user (act_as). The app token above books
everything as your app; a person on a booking is just an attendee. If your
users should instead reuse Keyspace's per-user scope (member booking quotas,
member-owned resources, per-member roles), exchange your app credentials for a
member-scoped token: same endpoint, grant_type: "act_as", plus
subject: "<your-user-id>" — the member is created and linked to your app on
first use, no Keyspace password or login involved. The delegated token is
short-lived, has no refresh token (re-run the grant), and can never exceed
your app's own scope. This is a coordinated feature — agree it with your
Keyspace contact first. Full detail + errors (KS221/KS223):
reference §act_as.
Your app owns its users? If you hold your users' profiles, access-card
numbers or face photos and want Keyspace to enrol them on the doors, that is a
separate surface — the Members & credentials guide.
The act_as subject above is the same id as that guide's externalUserId, so
a member you provision and a member you book as are one person.
Step 2 — Find something to book
Rooms — GET /meeting-rooms?projectId=…&status=active. Add a window to filter to
what's free:
GET /meeting-rooms?projectId=<id>&status=active
&startDateTime=2026-07-15T14:00:00+07:00
&endDateTime=2026-07-15T15:00:00+07:00Rooms with a conflicting booking are dropped from the result — including a room
whose cleaning time (cleanupMinutes, below) the window would run into. A room
that is free but not bookable for that window carries an unavailableReason
(OPERATING_HOURS or BREAK_TIME); skip those. No unavailableReason ⇒
bookable.
Desks — GET /desks?projectId=…. For availability, pass startDateTime plus
exactly one of timePeriod (morning/afternoon/allDay) or endDateTime
(explicit window). A desk is dropped when it is booked — or when the window
would run into a booking's cleaning time (desks use the property's default).
Read the asset's config before you build the booking form:
config.dayConfig— the operating hours that matter, per weekday, each withavailableTime {start,end}and optionalbreakTimes[]. A missing weekday means closed that day. (config.start/config.endare legacy overall hours — preferdayConfig.)config.minimumLeadTime— how far ahead a booking must start.config.maxAdvanceBookingDays/maxDurationMinutes— booking-horizon and length limits.cleanupMinutes(on the room itself, 0–60) — cleaning time reserved after every booking. A booking holds the room until its end plus the cleaning: with 15 minutes, a 10:00–11:00 booking keeps the room until 11:15. Absent means the property's default (possibly none; desks always use it). The room availability search above already accounts for it.
Times inside config are property-local wall-clock (HH:mm:ss).
Step 3 — Create the booking
POST /meetings. Rules to enforce client-side (the server enforces them too, but
failing fast is a better UX):
- Exactly one of
meetingRoomId/deskId. - Rooms use
startDateTime+endDateTime. Desk bookings usestartDateTime+ exactly one ofendDateTime/timePeriod. - The window must sit inside the day's operating hours, avoid
breakTimes, meetminimumLeadTime, and stay withinmaxAdvanceBookingDays/maxDurationMinutes. - Leave the cleaning time free: start no earlier than the previous booking's end
plus the cleaning, and end at least that long before the next booking. A
booking that runs into it gets the same
409as an overlap, with a message that says when the cleaning ends.
Preview price before committing with draft: true (nothing is created); use
draftEndDateTimes to price several durations in one call.
Booking for a member (back office). If your app acts for front-desk staff,
pass organizerId — the member's Keyspace user id. The caller must hold
MEETING_UPDATE on the asset's project (403 otherwise) and the member must
belong to it (400). The member becomes the organizer (it shows in their app,
they get the reminders, they may check in); your caller is recorded as
bookedByUserId and bookedVia marks the booking as back-office; promo codes
are priced in the member's scope. Nothing is charged at create: for a paid room
the response is status: "unpaid" with a kind: "quote" billing entry and
no webPaymentUrl — the member pays from their own device before
paymentDueAt (see Step 4). draft: true previews the price the same way.
The success response carries the booking _id and a pinCode — store both.
The pinCode authorizes later update/cancel/check-in/check-out when the caller is
not the organizer. Treat it as a secret; never log it.
Step 4 — Handle payment (paid rooms only)
If the create (or an extending update) returns status: "unpaid", payment is
required. It is a human web flow — do not try to automate it.
- Present
payment.webPaymentUrl(or renderpayment.webPaymentQr) to the user. - The window is ~5 minutes (
billingSummaries[].expiredAt); past it the booking auto-cancels. A back-office booking (organizerId) is instead held untilpaymentDueAt—paymentDueMinuteson the request (back-office only) → the project'sbackofficePaymentDueMinutes→ 1440 minutes, clamped to 7 days and to the meeting start — and there is nowebPaymentUrlto show: the member pays it in the Keyspace app. - Pass
frontendRedirectUrlon create so the payer returns into your app after paying. - Confirm by polling
GET /meetings/:iduntilstatusleavesunpaid.
Step 5 — Manage the booking
- Update / extend —
PATCH /meetings/:id(onlyactive/checked-in; start time is frozen after check-in; extending a paid room can return a newunpaidpayment step). - Cancel —
POST /meetings/:id/cancel(fromunpaid/activeonly — achecked-inbooking cannot be cancelled, check out instead; irreversible; voids pending payment). - Check-in —
POST /meetings/:id/check-in, opens ~15 min before start;activeonly. Do not skip this: anactivebooking with no check-in is auto-cancelled ~15 minutes after start (unless the asset disables it). - Check-out —
POST /meetings/:id/check-out,checked-inonly, irreversible — and the only way to end achecked-inbooking early.
Include pinCode in the body when the caller is not the organizer. State rules,
timers and every refusal code: section 3.
5. Error handling strategy
- Branch on HTTP status + the body
code; treatmessageas human text, not a stable contract. On400(KS001),messageis an array of constraint strings — surface them. 409 KS012(time-slot conflict) is a normal, recoverable outcome of two users racing for the same slot — catch it and offer another slot, don't treat it as a crash. The same refusal covers a booking that runs into the cleaning time (cleanupMinutes); its message says when the cleaning ends — show it.403 MEETING_049means an admin has blocked the organizer from booking. It comes back fromPOST /meetings, and fromPATCH /meetings/:idwhen the change moves the start or end. It is not a slot problem, so don't send it to your "offer another slot" branch. Show the message and point the user to their building admin; no other room, time or retry will work until the block is lifted.403 KS003onPOST /meetingsmeans the room or desk is private (isPublic: false) and the organizer is not a member of its project. Offer a public room or desk instead, or ask the building admin to add the user.- Retry only network failures and
5xx(and429), exponential backoff, max 3. Never auto-retry other4xx. - No idempotency key on create. If
POST /meetingstimes out, do not resend blindly — you may double-book. Reconcile withGET /meetings?from=…&to=…&meetingRoomId=…to see whether the booking landed, then decide. _idis not unique in afrom/tolist response. That endpoint expands a recurring series into one row per occurrence, all sharing the series master's_id. Key and de-duplicate onoccurrenceEventIdwhen it is present; keep using_idfor check-in/cancel/detail. NoteGET /meetings/:idreturns the master, whose window rolls forward across the series, so it will not match the occurrence row you started from.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | KS001 | Validation failed (message[]) |
| 401 | KS002 | Token missing/expired |
| 403 | KS003 | No access to the resource |
| 404 | KS005 | Not found |
| 409 / 410 | KS006 | State conflict / target already gone |
| 409 | KS012 | Time-slot conflict (recoverable) |
| 429 | KS036 | Too many requests |
| 500 / 503 | KS004 | Server / upstream error (retryable) |
| 503 | KS234 | Authentication temporarily unavailable — retry with backoff, don't re-authenticate |
| 403 | KS221 | act_as / partner members: not your linked member (or not a single-project app) |
| 401 | KS223 | act_as / partner members: subject not resolved |
A KSxxx code is not 1:1 with an HTTP status (KS006 spans 409/410,
KS004 spans 500/503) — branch on the status, log the code. Full reference:
error codes.
6. Timezones
Send every date-time in ISO 8601 with an explicit offset
(2026-07-15T14:00:00+07:00). Render times back to users in the property's
local timezone. config hours are property-local HH:mm:ss.
7. Receiving events on your server (webhooks)
Give your Keyspace contact an HTTPS callback URL, a 32+ character secret you generate, and the event types you want. Keyspace then POSTs each event to your URL with these headers:
| Header | Meaning |
|---|---|
X-Keyspace-Event | event type, e.g. ROOM_CANCEL |
X-Keyspace-Delivery | unique delivery id — de-duplicate retries on it |
X-Keyspace-Timestamp | epoch ms — reject if older than ~5 min (replay guard) |
X-Keyspace-Signature | sha256=HEX(HMAC-SHA256(secret, timestamp + "." + rawBody)) |
Rules of the road:
- Verify the signature over the raw body bytes before trusting anything (code sample in the full reference).
- Ack fast: respond 2xx within 10 s; process async. Failures retry up to 5× with exponential backoff; 20 consecutive dead deliveries auto-disable your webhook until Keyspace re-enables it.
- The payload is a signal, not the record — re-fetch
GET /meetings/:idfor authoritative state; the payload never containspinCode. - Branch cancels on
data.cancelReason: absent = user/admin cancel,"No-show — auto-cancelled by system","Payment window lapsed — auto-cancelled by system", or"Unpaid — back-office hold expired".
Event types: ROOM_/DESK_BOOK, _MODIFY, _CANCEL, _REMINDER,
_NO_SHOW_WARNING, _ENDING_SOON, _PAYMENT_DUE, plus visitor events VMS_INVITE_SENT,
VMS_VISITOR_CHECKED_IN, VMS_HOST_CANCELLED — full catalog + payload fields in
the reference. Visitor events ride the same channel; note
VMS_INVITE_SENT carries the visitor's access QR (qrCardCode) for you to relay
to the invitee — the one payload field you should treat as a credential. A visitor
invite may have no end time: its endDateTime is then null, and it is valid the
whole start day.
If your endpoint needs its own auth (beyond verifying our signature),
Keyspace can present a bearer token, a custom-header API key, or a
short-lived jwt (HS256 over the shared secret) — pick the scheme at
registration. Default is signature-only.
8. Sandbox testing checklist
Run against dev/UAT before you ship:
- Authenticate, then let the token expire and confirm your refresh path.
- List rooms; list again with a time window and confirm busy rooms drop out.
-
draft: truecreate returns a price and creates nothing. - Real create returns
active(free room) orunpaid+webPaymentUrl(paid room). - Second overlapping create fails
409 KS012and your UI offers another slot. - Update changes a field; cancel flips to
cancelled. - Your smoke test cancels everything it created (try/finally) — never leave live bookings behind.
- Confirm you never log
pinCode, tokens, orapp_secret. - If using webhooks: your endpoint verifies the signature, acks within 10 s,
and de-duplicates on
X-Keyspace-Delivery.
9. Go-live checklist
- Production
app_id/app_secretstored in a secrets manager, not in code. - Token cached per process; proactive refresh + one-shot 401 recovery in place.
-
409 KS012handled as a user-facing "pick another slot", not an error page. - Timed-out creates reconcile via
GET /meetings, never blind-retry. - Paid-room flow polls to
activebefore showing success; honours the 5-min window andfrontendRedirectUrl. - All date-times sent with an offset; times rendered in the property timezone.
10. Getting help & reporting discrepancies
If the API's observed behavior differs from these docs, note the exact endpoint, what you expected, and what you saw, and send it to your Keyspace contact — the docs are served from the API itself and we correct drift at the source.
Getting started
Version 1.12.0 · The partner "start here" for every Keyspace integration: credentials, authentication, the response envelope, webhooks — and the index of per-module guides. For field-by-field…
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…