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
EnvironmentBase URLUse for
Developmenthttps://api.keyspace-dev.comEarly build + smoke tests
UAThttps://api.keyspace-qat.comPre-production sign-off
Productionhttps://api.keyspace.techLive

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 same POST /meetings endpoint — a room booking passes meetingRoomId, a desk booking passes deskId.
  • Envelope. Every response except the token endpoint is wrapped: { code, success, message, data, meta? }. code: "KS000" means success; any other KSxxx is an error. Unwrap data; read meta on paginated lists.
  • Lifecycle. A booking moves unpaid → active → checked-in → done, with cancelled reachable from unpaid and active only — a checked-in booking cannot be cancelled; it ends via check-out (organizer or pinCode) or automatically at the booking's end time. rejected is a terminal decline. Free rooms skip unpaid and land on active immediately.
  • 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/:id polling 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

StatusMeaningAllowed callsLeaves via
unpaidPaid room (or a paid extension): the slot is held while the user pays through payment.webPaymentUrl / webPaymentQrcancelpayment settled → active; cancel, or the 5-minute payment window lapsing → cancelled
activeConfirmed, nobody checked in yetPATCH, cancel, check-in (inside the window)check-in → checked-in; cancel or no-show auto-cancel → cancelled; end time → done
checked-inSomeone checked in; the room and its devices are livePATCH (end time only), check-outcheck-out, or end time → done
doneEnded — checked out, or closed by the clock—terminal
cancelledCancelled by a user, an admin, your app, or the system (then cancelReason is set)—terminal
rejectedCame 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:

CallStateResponse
PATCH /meetings/:idunpaid422 "Cannot edit unpaid meeting" — pay or cancel first
PATCH moving startDateTimechecked-in409 "Start time of a checked-in meeting cannot be modified." — the end may still be extended
PATCHcancelledKS072 "The meeting has already cancelled"
POST /meetings/:id/cancelchecked-in, done, cancelled409 MEETING_011 — a started booking ends by check-out, never by cancel
POST /meetings/:id/check-inanything but active inside the windowsee 3.4

3.2 Where a new booking starts

  • Free room or desk → active on the 201. Paid room → unpaid with a payment object; it becomes active when the payment settles (poll GET /meetings/:id, or wait for ROOM_BOOK / DESK_BOOK).
  • Rooms/desks configured with postStartExpireMinutes: 0 ("check-in not required"): a booking created inside the check-in window is checked-in on the 201; otherwise Keyspace checks the organizer in at the start time itself (history entry source: "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 happensWebhook
booking + 5 min, still unpaidauto-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 inlast warning, carries minutesUntilAutoCancel_NO_SHOW_WARNING
start + postStartExpireMinutes (15), nobody checked inauto-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 timeactive / checked-in → donenone
  • postStartExpireMinutes: -1 — no warning and no auto-cancel: an unattended booking stays active until its end time, then closes to done.
  • All-day bookings are never no-show-cancelled.
  • There is no webhook for check-in, check-out or done — poll GET /meetings/:id for 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 becomes done only 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:

SituationHTTPCode
booking is cancelled404MEETING_043
booking already ended409MEETING_004
still unpaid422MEETING_005
too early — the message says how many minutes before start the window opens409MEETING_040
wrong day (assets with preStartMinutes: -1)409MEETING_041
wrong pinCode404MEETING_042
PIN locked after repeated wrong attempts — stays locked until the organizer sets a new PIN429MEETING_039
caller is neither organizer nor attendee and sent no pinCode404MEETING_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, pinCode unchanged (ROOM_MODIFY). If the new slot breaks a booking rule the room declines the invite, the booking is cancelled with 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: done with the actual end time. The meeting happened, so it is not cancelled.
  • Room copy removed / room declined while the organizer's copy still stands — cancelled with 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 503 KS234: 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_in from 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:00

Rooms 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 with availableTime {start,end} and optional breakTimes[]. A missing weekday means closed that day. (config.start/config.end are legacy overall hours — prefer dayConfig.)
  • 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 use startDateTime + exactly one of endDateTime / timePeriod.
  • The window must sit inside the day's operating hours, avoid breakTimes, meet minimumLeadTime, and stay within maxAdvanceBookingDays / 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 409 as 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 render payment.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 until paymentDueAt — paymentDueMinutes on the request (back-office only) → the project's backofficePaymentDueMinutes → 1440 minutes, clamped to 7 days and to the meeting start — and there is no webPaymentUrl to show: the member pays it in the Keyspace app.
  • Pass frontendRedirectUrl on create so the payer returns into your app after paying.
  • Confirm by polling GET /meetings/:id until status leaves unpaid.

Step 5 — Manage the booking

  • Update / extend — PATCH /meetings/:id (only active/checked-in; start time is frozen after check-in; extending a paid room can return a new unpaid payment step).
  • Cancel — POST /meetings/:id/cancel (from unpaid/active only — a checked-in booking cannot be cancelled, check out instead; irreversible; voids pending payment).
  • Check-in — POST /meetings/:id/check-in, opens ~15 min before start; active only. Do not skip this: an active booking with no check-in is auto-cancelled ~15 minutes after start (unless the asset disables it).
  • Check-out — POST /meetings/:id/check-out, checked-in only, irreversible — and the only way to end a checked-in booking 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; treat message as human text, not a stable contract. On 400 (KS001), message is 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_049 means an admin has blocked the organizer from booking. It comes back from POST /meetings, and from PATCH /meetings/:id when 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 KS003 on POST /meetings means 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 (and 429), exponential backoff, max 3. Never auto-retry other 4xx.
  • No idempotency key on create. If POST /meetings times out, do not resend blindly — you may double-book. Reconcile with GET /meetings?from=…&to=…&meetingRoomId=… to see whether the booking landed, then decide.
  • _id is not unique in a from/to list response. That endpoint expands a recurring series into one row per occurrence, all sharing the series master's _id. Key and de-duplicate on occurrenceEventId when it is present; keep using _id for check-in/cancel/detail. Note GET /meetings/:id returns the master, whose window rolls forward across the series, so it will not match the occurrence row you started from.
HTTPCodeMeaning
400KS001Validation failed (message[])
401KS002Token missing/expired
403KS003No access to the resource
404KS005Not found
409 / 410KS006State conflict / target already gone
409KS012Time-slot conflict (recoverable)
429KS036Too many requests
500 / 503KS004Server / upstream error (retryable)
503KS234Authentication temporarily unavailable — retry with backoff, don't re-authenticate
403KS221act_as / partner members: not your linked member (or not a single-project app)
401KS223act_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:

HeaderMeaning
X-Keyspace-Eventevent type, e.g. ROOM_CANCEL
X-Keyspace-Deliveryunique delivery id — de-duplicate retries on it
X-Keyspace-Timestampepoch ms — reject if older than ~5 min (replay guard)
X-Keyspace-Signaturesha256=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/:id for authoritative state; the payload never contains pinCode.
  • 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: true create returns a price and creates nothing.
  • Real create returns active (free room) or unpaid + webPaymentUrl (paid room).
  • Second overlapping create fails 409 KS012 and 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, or app_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_secret stored in a secrets manager, not in code.
  • Token cached per process; proactive refresh + one-shot 401 recovery in place.
  • 409 KS012 handled 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 active before showing success; honours the 5-min window and frontendRedirectUrl.
  • 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.

On this page