Skip to content

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_secret as every other module (getting started §2). This surface takes the plain app_credentials token only; a delegated act_as token is 403.
  • One project per app. Member routes are scoped to the single projectId your 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

  • externalUserId is yours. Every route is keyed by your id for the person (opaque to Keyspace, ≤ 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 reach members your app created or linked: any other id is 403 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 → emailphoneemployeeId, first hit wins — and only creates when nothing matches (§3).
  • Members, not guests. People you PUT are project members (userType: member, source: partner). People who only ever appear as an act_as subject 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 PUT overwrite theirs; fields you omit stay as they are.
  • Credentials are declarative. PUT …/credentials describes 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 appSendWhy
Office / workplace (HR system, employee app)employeeId — plus email when the tenant signs in with Office / MicrosoftemployeeId 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 homeaddressNo 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:

CauseWhat to do
The body's email/phone/employeeId points at a different member than the one already linked to this externalUserIdFix the key on your side, or DELETE the wrong link first
Two of your users share an employeeId / emailCorrect your data — one person, one id
email / phone / employeeId already taken by someone else in KeyspaceAsk the building admin to resolve the duplicate
unit.addressNo exists in several zonesSend 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. cardNo is read in cardCodeFormat, default decimal-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 is 400.
  • face — base64 JPEG/PNG (bare or data:image/…;base64,), ≤ 10 MB decoded, type verified by magic bytes. A string sets/replaces and requires faceConsentAt; null erases (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 that cardNo and 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 / codeMeaningHandle
400 KS001validation — missing faceConsentAt, bad card number, bad image, lastName missingfix the request; don't retry as-is
401 KS002token expired / invalidrefresh → retry once → re-authenticate
401 KS223member could not be resolvedtreat as "not linked"; PUT to create
403 KS003not an app token (you sent a delegated token)use the app_credentials token
403 KS221not your member, or not a single-project appcheck the id; talk to your contact about scope
404 KS005unit names a room the project does not haveverify addressNo / zoneId with the building admin
409 KS006offboarded member (credentials), duplicate email / phone / employeeId, identity-key conflictread data.conflictKey / data.linkedExternalUserId — table in Step 2
429 KS036rate limitedback 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.
  • [ ] PUT a new member → created: true; PUT again with a changed profileNamecreated: false, matchedBy: "link".
  • [ ] PUT a second externalUserId with the first member's employeeId409 KS006 with linkedExternalUserId; handled, not crashed.
  • [ ] Condo: PUT with unitunit.role: "owner"; second resident on the same unit → "participant"; unit: nullunit: null.
  • [ ] Credentials: face + consent → one generated: true card; add a physical card → both listed; cards: [] → physical revoked, auto card kept.
  • [ ] Face without faceConsentAt400.
  • [ ] GET shows the summary; DELETE204; GETstatus: "deactivated"; PUT → same memberId, status: "active".
  • [ ] Optional: act_as with the same subject books a room as that member.

8. Go-live checklist

  • [ ] Production app_id / app_secret issued and stored server-side only.
  • [ ] Access groups confirmed with your Keyspace contact (accessGroupIds non-empty on the first production PUT).
  • [ ] 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.