Appearance
Keyspace Members & Credentials — Integration Guide
Version 1.11.1 · 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 doors: profile, access-card numbers, face photos.
This guide is the narrative — who owns what, which identity keys to send, and the journey a member travels from your first PUT to offboarding. For field-by-field detail see the full API reference §5; for the machine-readable contract see the OpenAPI spec. Platform basics (credentials, token, envelope, webhooks) are on the getting-started page.
1. Before you start
- Credentials — the same
app_id/app_secretas every other module (getting started §2). This surface takes the plainapp_credentialstoken only; a delegatedact_astoken is403. - One project per app. Member routes are scoped to the single
projectIdyour app was provisioned for. A multi-project app cannot use them. - Doors are chosen by Keyspace, not by you. At provisioning your Keyspace contact attaches a fixed set of access groups to your app. Every member you create is added to those groups automatically — that membership, never a credential on its own, is what makes devices accept the member's cards and face (credential ≠ permission). Need different doors? Ask your contact; there is no API for it.
- Face photos are biometric data. A face upload requires the instant the person consented (
faceConsentAt, ISO-8601). Collect and store that consent in your own app; Keyspace persists the timestamp for audit and never returns image bytes. - Rate limit — 30 requests / 60 s per app across the member routes (
429 KS036). Batch imports: throttle, don't retry blindly.
2. The five-minute mental model
externalUserIdis yours. Every route is keyed by your id for the person (opaque to Keyspace, ≤ 128 chars). The firstPUTbinds it to exactly one Keyspace member; every later call — includingact_aswith the samesubject— resolves to that member. You can only reach members your app created or linked: any other id is403 KS221(it never creates by accident).- Existing people are reused, never duplicated. Buildings already have members (admin console, bulk import, Office sign-in). Before creating, Keyspace resolves your body's identity keys — previous link →
email→phone→employeeId, first hit wins — and only creates when nothing matches (§3). - Members, not guests. People you
PUTare project members (userType: member,source: partner). People who only ever appear as anact_assubject are visitors (source: federated) until an admin promotes them. - What the building admin sees. Your members show in the Keyspace admin console with a Partner provenance chip. Admins can read them, adjust groups and edit fields, but cannot demote a partner member to a visitor. Fields you send on a later
PUToverwrite theirs; fields you omit stay as they are. - Credentials are declarative.
PUT …/credentialsdescribes the exact set the member should hold; Keyspace diffs it against the devices. Omit a field to leave that credential type untouched.
3. Choose your identity keys
Pick by what your app knows about a person. Keys are checked in the order below; unit is never an identity key — it says where someone lives, not who they are.
| Your app | Send | Why |
|---|---|---|
| Office / workplace (HR system, employee app) | employeeId — plus email when the tenant signs in with Office / Microsoft | employeeId is your badge / HR id, unique per project; valid alone (the member is created without a login, username {projectId}_{employeeId}). Sending the work email too means the member who later signs in through the Office add-in is the same one you provisioned. |
| Condo / residence (resident app) | email and/or phone to identify the person, unit: { addressNo, zoneId?, unitId? } to bind the home | addressNo is the house number the building admin entered on the room (404 if the project has no such room — partners never create rooms). zoneId disambiguates when the same number exists in several towers; unitId is an alternate key. First resident bound to a unit becomes its reservation owner, later ones participants; unit: null unbinds; a new unit moves them. |
| Booking-only (you just book rooms for people) | — | You don't need this surface. Pass people as attendees, or use act_as for per-user scope — see the meeting guide. |
email / phone must be unique across Keyspace; employeeId unique within the project. phone is normalised (spaces/dashes stripped, Thai numbers default to +66).
4. Step by step
Step 1 — Authenticate
bash
curl -X POST <baseUrl>/auth/accessToken \
-H "Content-Type: application/json" \
-d '{"app_id":"<app_id>","app_secret":"<app_secret>","grant_type":"app_credentials"}'
# → { access_token, expires_in, refresh_token, refresh_token_expires_in }Send Authorization: Bearer <access_token> on every call; refresh ~5 min before expires_in. Full token rules: reference §Authentication.
Step 2 — Create or update the member
PUT /partner/members/{externalUserId} — idempotent. First call creates, links and grants the fixed groups; later calls update the profile (omitted = untouched). firstName and lastName are required (empty lastName is accepted).
Office app:
json
{ "firstName": "Somchai", "lastName": "Jaidee", "employeeId": "HR-0042",
"email": "[email protected]" }Condo app:
json
{ "firstName": "Somchai", "lastName": "Jaidee", "phone": "+66812345678",
"unit": { "addressNo": "12/34", "zoneId": "66a1…" } }Response 200 — keep memberId; read created and matchedBy to learn whether you created a new person or picked up an existing one (link | email | phone | employeeId | provisioned):
json
{
"code": "KS000", "success": true,
"data": {
"memberId": "66b1f0a2c9e77a0012345678",
"externalUserId": "u-123",
"projectId": "66a0…",
"created": true,
"matchedBy": "provisioned",
"accessGroupIds": ["66c2…"],
"employeeId": "HR-0042",
"unit": { "roomId": "66d3…", "addressNo": "12/34", "groupId": "66d4…", "role": "owner" }
}
}If accessGroupIds is empty, your app has no active partner groups yet — contact Keyspace before relying on door access.
409 KS006 — Keyspace never silently re-links. data.conflictKey names the key; when the person is already managed by you under another id, data.linkedExternalUserId tells you which. Typical causes and fixes:
| Cause | What to do |
|---|---|
The body's email/phone/employeeId points at a different member than the one already linked to this externalUserId | Fix the key on your side, or DELETE the wrong link first |
Two of your users share an employeeId / email | Correct your data — one person, one id |
email / phone / employeeId already taken by someone else in Keyspace | Ask the building admin to resolve the duplicate |
unit.addressNo exists in several zones | Send unit.zoneId |
Step 3 — Set the member's credentials
PUT /partner/members/{externalUserId}/credentials — a declarative set; each field independent, omit to leave alone.
json
{
"cards": [
{ "cardNo": "2140450341", "label": "Resident card" },
{ "cardNo": "25AE947F", "cardCodeFormat": "hex" }
],
"face": "data:image/jpeg;base64,/9j/4AAQSkZJRg…",
"faceConsentAt": "2026-09-01T09:30:00.000Z"
}cards— the exact physical cards the member should hold (max 20). Missing ones are enrolled, absent ones revoked, common ones kept;[]revokes all.cardNois read incardCodeFormat, defaultdecimal-reversed— the 10-digit number a Hikvision reader/terminal shows. Override per card only for another vendor's reader (see the OpenAPI enum). Matching is on the decoded card UID, so one card in two formats is one card; an unparseable number is400.face— base64 JPEG/PNG (bare ordata:image/…;base64,), ≤ 10 MB decoded, type verified by magic bytes. A string sets/replaces and requiresfaceConsentAt;nullerases (right to erasure).- Auto-generated card. If, after applying the set, the member has a face but holds no physical card, Keyspace mints one virtual card with a random project-unique number and returns it with
generated: true. Print or encode a physical card with thatcardNoand hand it over — it opens the same doors. It is generated once per member; physical cards you add later don't retire it; offboarding revokes it and a re-onboarded member gets a fresh number.
Response 200:
json
{
"code": "KS000", "success": true,
"data": {
"memberId": "66b1f0a2c9e77a0012345678",
"cards": [
{ "cardId": "66c3…", "cardNo": "2140450341", "label": "Resident card", "type": "real", "generated": false },
{ "cardId": "66c4…", "cardNo": "0982716354", "label": "auto-generated (face) 1B2C3D4E", "type": "virtual", "generated": true }
],
"face": { "present": true }
}
}cardNo in every response is the Hikvision decimal-reversed number, whatever you submitted. The member must be active — 409 when offboarded (re-PUT the member first).
Step 4 — Read back
GET /partner/members/{externalUserId} returns status (active | deactivated), source, userType, accessGroupIds, employeeId, unit, profile and a credential summary — cards as above, face: { present }. Face bytes are never returned. Never creates.
Step 5 — Move, rename, change a key
Another PUT with the changed fields. Moving a resident to a new unit removes them from the old one; unit: null unbinds. Changing email / phone / employeeId re-runs the identity checks — a 409 here means the new key already belongs to someone (see Step 2).
Step 6 — Offboard (and re-onboard)
DELETE /partner/members/{externalUserId} → 204. Every card (physical and auto-generated) is revoked, device access withdrawn, the member removed from your groups. The link is kept, so a later PUT re-activates the samememberId — no duplicates. Idempotent.
Step 7 — Book on behalf of the member (optional)
Same externalUserId as the act_as subject:
json
{ "grant_type": "act_as", "app_id": "…", "app_secret": "…",
"subject": "u-123", "subject_type": "external_user_id" }The delegated token resolves to the member you provisioned (not a new visitor) and books with that member's own quota and scope. Booking journey: meeting guide; token rules: reference §act_as.
5. Sequence — a resident's life in your app
6. Error handling
| Status / code | Meaning | Handle |
|---|---|---|
400 KS001 | validation — missing faceConsentAt, bad card number, bad image, lastName missing | fix the request; don't retry as-is |
401 KS002 | token expired / invalid | refresh → retry once → re-authenticate |
401 KS223 | member could not be resolved | treat as "not linked"; PUT to create |
403 KS003 | not an app token (you sent a delegated token) | use the app_credentials token |
403 KS221 | not your member, or not a single-project app | check the id; talk to your contact about scope |
404 KS005 | unit names a room the project does not have | verify addressNo / zoneId with the building admin |
409 KS006 | offboarded member (credentials), duplicate email / phone / employeeId, identity-key conflict | read data.conflictKey / data.linkedExternalUserId — table in Step 2 |
429 KS036 | rate limited | back off; resume after 60 s |
Envelope and the full code list: reference §Error Handling.
7. Sandbox checklist
Run against https://api.keyspace-dev.com with your dev credentials; make the script offboard everything it creates.
- [ ] Authenticate; refresh; recover from a forced
401. - [ ]
PUTa new member →created: true;PUTagain with a changedprofileName→created: false,matchedBy: "link". - [ ]
PUTa secondexternalUserIdwith the first member'semployeeId→409 KS006withlinkedExternalUserId; handled, not crashed. - [ ] Condo:
PUTwithunit→unit.role: "owner"; second resident on the same unit →"participant";unit: null→unit: null. - [ ] Credentials: face + consent → one
generated: truecard; add a physical card → both listed;cards: []→ physical revoked, auto card kept. - [ ] Face without
faceConsentAt→400. - [ ]
GETshows the summary;DELETE→204;GET→status: "deactivated";PUT→ samememberId,status: "active". - [ ] Optional:
act_aswith the same subject books a room as that member.
8. Go-live checklist
- [ ] Production
app_id/app_secretissued and stored server-side only. - [ ] Access groups confirmed with your Keyspace contact (
accessGroupIdsnon-empty on the first productionPUT). - [ ] Consent capture live in your app before any face upload; erasure path (
face: null) wired to your privacy process. - [ ] Card numbers verified against a production reader (default
decimal-reversed; override only if the reader is not Hikvision). - [ ] Offboarding hooked to your leaver / move-out process.
- [ ] Throttling under the 30 req/min limit for bulk imports.
9. Getting help & reporting discrepancies
Found the API behaving differently from this guide? Send your Keyspace contact the endpoint, the externalUserId, what you expected and what you saw — the docs are served from the API and we fix drift at the source.